diff --git a/.ci/end2end.groovy b/.ci/end2end.groovy index 8ad810717d86e..8e9b041d32d3e 100644 --- a/.ci/end2end.groovy +++ b/.ci/end2end.groovy @@ -13,7 +13,7 @@ pipeline { BASE_DIR = 'src/github.com/elastic/kibana' HOME = "${env.WORKSPACE}" APM_ITS = 'apm-integration-testing' - CYPRESS_DIR = 'x-pack/legacy/plugins/apm/e2e' + CYPRESS_DIR = 'x-pack/plugins/apm/e2e' PIPELINE_LOG_LEVEL = 'DEBUG' } options { @@ -39,7 +39,7 @@ pipeline { shallow: false, reference: "/var/lib/jenkins/.git-references/kibana.git") script { dir("${BASE_DIR}"){ - def regexps =[ "^x-pack/legacy/plugins/apm/.*" ] + def regexps =[ "^x-pack/plugins/apm/.*" ] env.APM_UPDATED = isGitRegionMatch(patterns: regexps) } } diff --git a/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index 2655ca1b48c18..c87ca01354315 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -19,43 +19,48 @@ currentBuild.description = "ES: ${SNAPSHOT_VERSION}
Kibana: ${params.branch def SNAPSHOT_MANIFEST = "https://storage.googleapis.com/kibana-ci-es-snapshots-daily/${SNAPSHOT_VERSION}/archives/${SNAPSHOT_ID}/manifest.json" -kibanaPipeline(timeoutMinutes: 120) { +kibanaPipeline(timeoutMinutes: 150) { catchErrors { - retryable.enable(2) - withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { - parallel([ - 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), - 'x-pack-intake-agent': workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh'), - 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ - 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), - 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), - 'oss-ciGroup3': kibanaPipeline.ossCiGroupProcess(3), - 'oss-ciGroup4': kibanaPipeline.ossCiGroupProcess(4), - 'oss-ciGroup5': kibanaPipeline.ossCiGroupProcess(5), - 'oss-ciGroup6': kibanaPipeline.ossCiGroupProcess(6), - 'oss-ciGroup7': kibanaPipeline.ossCiGroupProcess(7), - 'oss-ciGroup8': kibanaPipeline.ossCiGroupProcess(8), - 'oss-ciGroup9': kibanaPipeline.ossCiGroupProcess(9), - 'oss-ciGroup10': kibanaPipeline.ossCiGroupProcess(10), - 'oss-ciGroup11': kibanaPipeline.ossCiGroupProcess(11), - 'oss-ciGroup12': kibanaPipeline.ossCiGroupProcess(12), - ]), - 'kibana-xpack-agent': workers.functional('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ - 'xpack-ciGroup1': kibanaPipeline.xpackCiGroupProcess(1), - 'xpack-ciGroup2': kibanaPipeline.xpackCiGroupProcess(2), - 'xpack-ciGroup3': kibanaPipeline.xpackCiGroupProcess(3), - 'xpack-ciGroup4': kibanaPipeline.xpackCiGroupProcess(4), - 'xpack-ciGroup5': kibanaPipeline.xpackCiGroupProcess(5), - 'xpack-ciGroup6': kibanaPipeline.xpackCiGroupProcess(6), - 'xpack-ciGroup7': kibanaPipeline.xpackCiGroupProcess(7), - 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), - 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), - 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), - ]), - ]) - } + slackNotifications.onFailure( + title: ":broken_heart: *<${env.BUILD_URL}|[${SNAPSHOT_VERSION}] ES Snapshot Verification Failure>*", + message: ":broken_heart: [${SNAPSHOT_VERSION}] ES Snapshot Verification Failure", + ) { + retryable.enable(2) + withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { + parallel([ + 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh'), + 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ + 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), + 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), + 'oss-ciGroup3': kibanaPipeline.ossCiGroupProcess(3), + 'oss-ciGroup4': kibanaPipeline.ossCiGroupProcess(4), + 'oss-ciGroup5': kibanaPipeline.ossCiGroupProcess(5), + 'oss-ciGroup6': kibanaPipeline.ossCiGroupProcess(6), + 'oss-ciGroup7': kibanaPipeline.ossCiGroupProcess(7), + 'oss-ciGroup8': kibanaPipeline.ossCiGroupProcess(8), + 'oss-ciGroup9': kibanaPipeline.ossCiGroupProcess(9), + 'oss-ciGroup10': kibanaPipeline.ossCiGroupProcess(10), + 'oss-ciGroup11': kibanaPipeline.ossCiGroupProcess(11), + 'oss-ciGroup12': kibanaPipeline.ossCiGroupProcess(12), + ]), + 'kibana-xpack-agent': workers.functional('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup1': kibanaPipeline.xpackCiGroupProcess(1), + 'xpack-ciGroup2': kibanaPipeline.xpackCiGroupProcess(2), + 'xpack-ciGroup3': kibanaPipeline.xpackCiGroupProcess(3), + 'xpack-ciGroup4': kibanaPipeline.xpackCiGroupProcess(4), + 'xpack-ciGroup5': kibanaPipeline.xpackCiGroupProcess(5), + 'xpack-ciGroup6': kibanaPipeline.xpackCiGroupProcess(6), + 'xpack-ciGroup7': kibanaPipeline.xpackCiGroupProcess(7), + 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), + 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), + 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + ]), + ]) + } - promoteSnapshot(SNAPSHOT_VERSION, SNAPSHOT_ID) + promoteSnapshot(SNAPSHOT_VERSION, SNAPSHOT_ID) + } } kibanaPipeline.sendMail() diff --git a/.eslintignore b/.eslintignore index 4913192e81c1d..53b3d80720439 100644 --- a/.eslintignore +++ b/.eslintignore @@ -25,7 +25,7 @@ target /src/plugins/vis_type_timelion/public/_generated_/** /src/plugins/vis_type_timelion/public/webpackShims/jquery.flot.* /x-pack/legacy/plugins/**/__tests__/fixtures/** -/x-pack/legacy/plugins/apm/e2e/cypress/**/snapshots.js +/x-pack/plugins/apm/e2e/cypress/**/snapshots.js /x-pack/legacy/plugins/canvas/canvas_plugin /x-pack/legacy/plugins/canvas/canvas_plugin_src/lib/flot-charts /x-pack/legacy/plugins/canvas/shareable_runtime/build diff --git a/.eslintrc.js b/.eslintrc.js index 8b33ec83347a8..52a83452bd00f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -151,6 +151,16 @@ module.exports = { }, }, + /** + * New Platform client-side + */ + { + files: ['{src,x-pack}/plugins/*/public/**/*.{js,ts,tsx}'], + rules: { + 'import/no-commonjs': 'error', + }, + }, + /** * Files that require Elastic license headers instead of Apache 2.0 header */ @@ -183,6 +193,11 @@ module.exports = { { basePath: __dirname, zones: [ + { + target: ['(src|x-pack)/**/*', '!src/core/**/*'], + from: ['src/core/utils/**/*'], + errorMessage: `Plugins may only import from src/core/server and src/core/public.`, + }, { target: [ '(src|x-pack)/legacy/**/*', @@ -306,7 +321,7 @@ module.exports = { { files: [ 'x-pack/test/functional/apps/**/*.js', - 'x-pack/legacy/plugins/apm/**/*.js', + 'x-pack/plugins/apm/**/*.js', 'test/*/config.ts', 'test/*/config_open.ts', 'test/*/{tests,test_suites,apis,apps}/**/*', @@ -393,7 +408,7 @@ module.exports = { 'x-pack/**/*.test.js', 'x-pack/test_utils/**/*', 'x-pack/gulpfile.js', - 'x-pack/legacy/plugins/apm/public/utils/testHelpers.js', + 'x-pack/plugins/apm/public/utils/testHelpers.js', ], rules: { 'import/no-extraneous-dependencies': [ @@ -519,7 +534,7 @@ module.exports = { * APM overrides */ { - files: ['x-pack/legacy/plugins/apm/**/*.js'], + files: ['x-pack/plugins/apm/**/*.js'], rules: { 'no-unused-vars': ['error', { ignoreRestSiblings: true }], 'no-console': ['warn', { allow: ['error'] }], @@ -527,7 +542,7 @@ module.exports = { }, { plugins: ['react-hooks'], - files: ['x-pack/legacy/plugins/apm/**/*.{ts,tsx}'], + files: ['x-pack/plugins/apm/**/*.{ts,tsx}'], rules: { 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 'react-hooks/exhaustive-deps': ['error', { additionalHooks: '^useFetcher$' }], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6e616cf78c206..280cb6fbd1b1d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -66,7 +66,7 @@ /x-pack/plugins/drilldowns/ @elastic/kibana-app-arch # APM -/x-pack/legacy/plugins/apm/ @elastic/apm-ui +/x-pack/plugins/apm/ @elastic/apm-ui /x-pack/plugins/apm/ @elastic/apm-ui /x-pack/test/functional/apps/apm/ @elastic/apm-ui /src/legacy/core_plugins/apm_oss/ @elastic/apm-ui diff --git a/.github/paths-labeller.yml b/.github/paths-labeller.yml index 89e0af270c54d..d9d99fc1416e4 100644 --- a/.github/paths-labeller.yml +++ b/.github/paths-labeller.yml @@ -8,9 +8,8 @@ - "Feature:ExpressionLanguage": - "src/plugins/expressions/**/*.*" - "src/plugins/bfetch/**/*.*" - - "Team:apm" + - "Team:apm": + - "x-pack/plugins/apm/**/*.*" - "x-pack/plugins/apm/**/*.*" - - "x-pack/legacy/plugins/apm/**/*.*" - "Team:uptime": - "x-pack/plugins/uptime/**/*.*" - - "x-pack/legacy/plugins/uptime/**/*.*" diff --git a/.gitignore b/.gitignore index bd7a954f950e9..13c7cd5fb2769 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,6 @@ package-lock.json *.sublime-* npm-debug.log* .tern-project -x-pack/legacy/plugins/apm/tsconfig.json +x-pack/plugins/apm/tsconfig.json apm.tsconfig.json -/x-pack/legacy/plugins/apm/e2e/snapshots.js +/x-pack/plugins/apm/e2e/snapshots.js diff --git a/config/kibana.yml b/config/kibana.yml index 0780841ca057e..8725888159506 100644 --- a/config/kibana.yml +++ b/config/kibana.yml @@ -40,7 +40,7 @@ # the username and password that the Kibana server uses to perform maintenance on the Kibana # index at startup. Your Kibana users still need to authenticate with Elasticsearch, which # is proxied through the Kibana server. -#elasticsearch.username: "kibana" +#elasticsearch.username: "kibana_system" #elasticsearch.password: "pass" # Enables SSL and paths to the PEM-format SSL certificate and SSL key files, respectively. diff --git a/docs/apm/spans.asciidoc b/docs/apm/spans.asciidoc index 2eed339160fc4..c35fb115d2db4 100644 --- a/docs/apm/spans.asciidoc +++ b/docs/apm/spans.asciidoc @@ -1,38 +1,53 @@ [role="xpack"] [[spans]] -=== Span timeline +=== Trace sample timeline -TIP: A {apm-overview-ref-v}/transaction-spans.html[span] is the duration of a single event. -Spans are automatically captured by APM agents, and you can also define custom spans. -Each span has a type and is defined by a different color in the timeline/waterfall visualization. - -The span timeline visualization is a bird's-eye view of what your application was doing while it was trying to respond to the request that came in. +The trace sample timeline visualization is a bird's-eye view of what your application was doing while it was trying to respond to a request. This makes it useful for visualizing where the selected transaction spent most of its time. [role="screenshot"] image::apm/images/apm-transaction-sample.png[Example of distributed trace colors in the APM app in Kibana] View a span in detail by clicking on it in the timeline waterfall. -When you click on an SQL Select database query, +For example, when you click on an SQL Select database query, the information displayed includes the actual SQL that was executed, how long it took, and the percentage of the trace's total time. You also get a stack trace, which shows the SQL query in your code. Finally, APM knows which files are your code and which are just modules or libraries that you've installed. These library frames will be minimized by default in order to show you the most relevant stack trace. +TIP: A {apm-overview-ref-v}/transaction-spans.html[span] is the duration of a single event. +Spans are automatically captured by APM agents, and you can also define custom spans. +Each span has a type and is defined by a different color in the timeline/waterfall visualization. + [role="screenshot"] image::apm/images/apm-span-detail.png[Example view of a span detail in the APM app in Kibana] -If your span timeline is colorful, it's indicative of a <>. +[float] +[[distributed-tracing]] +==== Distributed tracing + +If your trace sample timeline is colorful, it's indicative of a distributed trace. Services in a distributed trace are separated by color and listed in the order they occur. [role="screenshot"] image::apm/images/apm-services-trace.png[Example of distributed trace colors in the APM app in Kibana] -Don't forget; a distributed trace includes more than one transaction. +As application architectures are shifting from monolithic to more distributed, service-based architectures, +distributed tracing has become a crucial feature of modern application performance monitoring. +It allows you to trace requests through your service architecture automatically, and visualize those traces in one single view in the APM app. +From initial web requests to your front-end service, to queries made to your back-end services, +this makes finding possible bottlenecks throughout your application much easier and faster. + +[role="screenshot"] +image::apm/images/apm-distributed-tracing.png[Example view of the distributed tracing in APM app in Kibana] + +Don't forget; by definition, a distributed trace includes more than one transaction. When viewing these distributed traces in the timeline waterfall, you'll see this image:apm/images/transaction-icon.png[APM icon] icon, which indicates the next transaction in the trace. These transactions can be expanded and viewed in detail by clicking on them. After exploring these traces, you can return to the full trace by clicking *View full trace*. + +TIP: Distributed tracing is supported by all APM agents, and there's no additional configuration needed. diff --git a/docs/apm/traces.asciidoc b/docs/apm/traces.asciidoc index 8eef3d9bed4db..52b4b618de466 100644 --- a/docs/apm/traces.asciidoc +++ b/docs/apm/traces.asciidoc @@ -4,7 +4,7 @@ TIP: Traces link together related transactions to show an end-to-end performance of how a request was served and which services were part of it. -In addition to the Traces overview, you can view your application traces in the <>. +In addition to the Traces overview, you can view your application traces in the <>. The *Traces* overview displays the entry transaction for all traces in your application. If you're using <>, this view is key to finding the critical paths within your application. @@ -17,25 +17,3 @@ If there's a particular endpoint you're worried about, you can click on it to vi [role="screenshot"] image::apm/images/apm-traces.png[Example view of the Traces overview in APM app in Kibana] - -[float] -[[distributed-tracing]] -==== Distributed tracing - -Elastic APM supports distributed tracing. -Distributed tracing is a key feature of modern application performance monitoring as application architectures are shifting from monolithic to more distributed, -service-based architectures. - -Distributed tracing allows APM users to automatically trace requests all the way through the service architecture, -and visualize those traces in one single view in the APM app. -This is accomplished by tracing all of the requests, from the initial web request to your front-end service, -to queries made to your back-end services. -This makes finding possible bottlenecks throughout your application much easier and faster. - -By definition, a distributed trace includes more than one transaction. -You can use the <> to view a waterfall display of all of the transactions from individual services that are connected in a trace. - -[role="screenshot"] -image::apm/images/apm-distributed-tracing.png[Example view of the distributed tracing in APM app in Kibana] - -TIP: Distributed tracing is supported by all APM agents, and there's no additional configuration needed. \ No newline at end of file diff --git a/docs/apm/transactions.asciidoc b/docs/apm/transactions.asciidoc index 2e1022e6d684c..8012c9108ca5e 100644 --- a/docs/apm/transactions.asciidoc +++ b/docs/apm/transactions.asciidoc @@ -95,7 +95,7 @@ It's the requests on the right, the ones taking longer than average, that we pro When you select one of these buckets, you're presented with up to ten trace samples. -Each sample has a span timeline waterfall that shows what a typical request in that bucket was doing. +Each sample has a trace timeline waterfall that shows what a typical request in that bucket was doing. By investigating this timeline waterfall, we can hopefully determine _why_ this request was slow and then implement a fix. [role="screenshot"] diff --git a/docs/development/core/public/kibana-plugin-core-public.assertnever.md b/docs/development/core/public/kibana-plugin-core-public.assertnever.md new file mode 100644 index 0000000000000..8fefd4450d49b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.assertnever.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [assertNever](./kibana-plugin-core-public.assertnever.md) + +## assertNever() function + +Can be used in switch statements to ensure we perform exhaustive checks, see https://www.typescriptlang.org/docs/handbook/advanced-types.html\#exhaustiveness-checking + +Signature: + +```typescript +export declare function assertNever(x: never): never; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| x | never | | + +Returns: + +`never` + diff --git a/docs/development/core/public/kibana-plugin-core-public.deepfreeze.md b/docs/development/core/public/kibana-plugin-core-public.deepfreeze.md new file mode 100644 index 0000000000000..7c879b659a852 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.deepfreeze.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [deepFreeze](./kibana-plugin-core-public.deepfreeze.md) + +## deepFreeze() function + +Apply Object.freeze to a value recursively and convert the return type to Readonly variant recursively + +Signature: + +```typescript +export declare function deepFreeze(object: T): RecursiveReadonly; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| object | T | | + +Returns: + +`RecursiveReadonly` + diff --git a/docs/development/core/public/kibana-plugin-core-public.freezable.md b/docs/development/core/public/kibana-plugin-core-public.freezable.md new file mode 100644 index 0000000000000..fee87dde25c28 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.freezable.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [Freezable](./kibana-plugin-core-public.freezable.md) + +## Freezable type + + +Signature: + +```typescript +export declare type Freezable = { + [k: string]: any; +} | any[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.getflattenedobject.md b/docs/development/core/public/kibana-plugin-core-public.getflattenedobject.md new file mode 100644 index 0000000000000..3ef9b6bf703eb --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.getflattenedobject.md @@ -0,0 +1,30 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [getFlattenedObject](./kibana-plugin-core-public.getflattenedobject.md) + +## getFlattenedObject() function + +Flattens a deeply nested object to a map of dot-separated paths pointing to all primitive values \*\*and arrays\*\* from `rootValue`. + +example: getFlattenedObject({ a: { b: 1, c: \[2,3\] } }) // => { 'a.b': 1, 'a.c': \[2,3\] } + +Signature: + +```typescript +export declare function getFlattenedObject(rootValue: Record): { + [key: string]: any; +}; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| rootValue | Record<string, any> | | + +Returns: + +`{ + [key: string]: any; +}` + diff --git a/docs/development/core/public/kibana-plugin-core-public.isrelativeurl.md b/docs/development/core/public/kibana-plugin-core-public.isrelativeurl.md new file mode 100644 index 0000000000000..3c2ffa6340a97 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.isrelativeurl.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [isRelativeUrl](./kibana-plugin-core-public.isrelativeurl.md) + +## isRelativeUrl() function + +Determine if a url is relative. Any url including a protocol, hostname, or port is not considered relative. This means that absolute \*paths\* are considered to be relative \*urls\* + +Signature: + +```typescript +export declare function isRelativeUrl(candidatePath: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| candidatePath | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index adc87de2b9e7e..c24e4cf908b87 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -27,6 +27,16 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AppNavLinkStatus](./kibana-plugin-core-public.appnavlinkstatus.md) | Status of the application's navLink. | | [AppStatus](./kibana-plugin-core-public.appstatus.md) | Accessibility status of an application. | +## Functions + +| Function | Description | +| --- | --- | +| [assertNever(x)](./kibana-plugin-core-public.assertnever.md) | Can be used in switch statements to ensure we perform exhaustive checks, see https://www.typescriptlang.org/docs/handbook/advanced-types.html\#exhaustiveness-checking | +| [deepFreeze(object)](./kibana-plugin-core-public.deepfreeze.md) | Apply Object.freeze to a value recursively and convert the return type to Readonly variant recursively | +| [getFlattenedObject(rootValue)](./kibana-plugin-core-public.getflattenedobject.md) | Flattens a deeply nested object to a map of dot-separated paths pointing to all primitive values \*\*and arrays\*\* from rootValue.example: getFlattenedObject({ a: { b: 1, c: \[2,3\] } }) // => { 'a.b': 1, 'a.c': \[2,3\] } | +| [isRelativeUrl(candidatePath)](./kibana-plugin-core-public.isrelativeurl.md) | Determine if a url is relative. Any url including a protocol, hostname, or port is not considered relative. This means that absolute \*paths\* are considered to be relative \*urls\* | +| [modifyUrl(url, urlModifier)](./kibana-plugin-core-public.modifyurl.md) | Takes a URL and a function that takes the meaningful parts of the URL as a key-value object, modifies some or all of the parts, and returns the modified parts formatted again as a url.Url Parts sent: - protocol - slashes (does the url have the //) - auth - hostname (just the name of the host, no port or auth information) - port - pathname (the path after the hostname, no query or hash, starts with a slash if there was a path) - query (always an object, even when no query on original url) - hashWhy? - The default url library in node produces several conflicting properties on the "parsed" output. Modifying any of these might lead to the modifications being ignored (depending on which property was modified) - It's not always clear whether to use path/pathname, host/hostname, so this tries to add helpful constraints | + ## Interfaces | Interface | Description | @@ -118,6 +128,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | | [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) | UiSettings parameters defined by the plugins. | | [UiSettingsState](./kibana-plugin-core-public.uisettingsstate.md) | | +| [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) | We define our own typings because the current version of @types/node declares properties to be optional "hostname?: string". Although, parse call returns "hostname: null \| string". | | [UserProvidedValues](./kibana-plugin-core-public.userprovidedvalues.md) | Describes the values explicitly set by user. | ## Type Aliases @@ -139,6 +150,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeHelpExtensionMenuLink](./kibana-plugin-core-public.chromehelpextensionmenulink.md) | | | [ChromeNavLinkUpdateableFields](./kibana-plugin-core-public.chromenavlinkupdateablefields.md) | | | [FatalErrorsStart](./kibana-plugin-core-public.fatalerrorsstart.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | +| [Freezable](./kibana-plugin-core-public.freezable.md) | | | [HandlerContextType](./kibana-plugin-core-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-core-public.handlerfunction.md) to represent the type of the context. | | [HandlerFunction](./kibana-plugin-core-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-core-public.icontextcontainer.md) | | [HandlerParameters](./kibana-plugin-core-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-core-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-core-public.handlercontexttype.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.modifyurl.md b/docs/development/core/public/kibana-plugin-core-public.modifyurl.md new file mode 100644 index 0000000000000..b174f733a5c64 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.modifyurl.md @@ -0,0 +1,31 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [modifyUrl](./kibana-plugin-core-public.modifyurl.md) + +## modifyUrl() function + +Takes a URL and a function that takes the meaningful parts of the URL as a key-value object, modifies some or all of the parts, and returns the modified parts formatted again as a url. + +Url Parts sent: - protocol - slashes (does the url have the //) - auth - hostname (just the name of the host, no port or auth information) - port - pathname (the path after the hostname, no query or hash, starts with a slash if there was a path) - query (always an object, even when no query on original url) - hash + +Why? - The default url library in node produces several conflicting properties on the "parsed" output. Modifying any of these might lead to the modifications being ignored (depending on which property was modified) - It's not always clear whether to use path/pathname, host/hostname, so this tries to add helpful constraints + +Signature: + +```typescript +export declare function modifyUrl(url: string, urlModifier: (urlParts: URLMeaningfulParts) => Partial | void): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| url | string | | +| urlModifier | (urlParts: URLMeaningfulParts) => Partial<URLMeaningfulParts> | void | | + +Returns: + +`string` + +The modified and reformatted url + diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.auth.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.auth.md new file mode 100644 index 0000000000000..238dd66885896 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.auth.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) > [auth](./kibana-plugin-core-public.urlmeaningfulparts.auth.md) + +## URLMeaningfulParts.auth property + +Signature: + +```typescript +auth?: string | null; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.hash.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.hash.md new file mode 100644 index 0000000000000..161e7dc7ebfae --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.hash.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) > [hash](./kibana-plugin-core-public.urlmeaningfulparts.hash.md) + +## URLMeaningfulParts.hash property + +Signature: + +```typescript +hash?: string | null; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.hostname.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.hostname.md new file mode 100644 index 0000000000000..f1884718337b5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.hostname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) > [hostname](./kibana-plugin-core-public.urlmeaningfulparts.hostname.md) + +## URLMeaningfulParts.hostname property + +Signature: + +```typescript +hostname?: string | null; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.md new file mode 100644 index 0000000000000..2816d4c7df541 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) + +## URLMeaningfulParts interface + +We define our own typings because the current version of @types/node declares properties to be optional "hostname?: string". Although, parse call returns "hostname: null \| string". + +Signature: + +```typescript +export interface URLMeaningfulParts +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [auth](./kibana-plugin-core-public.urlmeaningfulparts.auth.md) | string | null | | +| [hash](./kibana-plugin-core-public.urlmeaningfulparts.hash.md) | string | null | | +| [hostname](./kibana-plugin-core-public.urlmeaningfulparts.hostname.md) | string | null | | +| [pathname](./kibana-plugin-core-public.urlmeaningfulparts.pathname.md) | string | null | | +| [port](./kibana-plugin-core-public.urlmeaningfulparts.port.md) | string | null | | +| [protocol](./kibana-plugin-core-public.urlmeaningfulparts.protocol.md) | string | null | | +| [query](./kibana-plugin-core-public.urlmeaningfulparts.query.md) | ParsedQuery | | +| [slashes](./kibana-plugin-core-public.urlmeaningfulparts.slashes.md) | boolean | null | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.pathname.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.pathname.md new file mode 100644 index 0000000000000..5ad21f004481c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.pathname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) > [pathname](./kibana-plugin-core-public.urlmeaningfulparts.pathname.md) + +## URLMeaningfulParts.pathname property + +Signature: + +```typescript +pathname?: string | null; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.port.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.port.md new file mode 100644 index 0000000000000..2e70da2f17421 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.port.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) > [port](./kibana-plugin-core-public.urlmeaningfulparts.port.md) + +## URLMeaningfulParts.port property + +Signature: + +```typescript +port?: string | null; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.protocol.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.protocol.md new file mode 100644 index 0000000000000..cedc7f0b878e3 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.protocol.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) > [protocol](./kibana-plugin-core-public.urlmeaningfulparts.protocol.md) + +## URLMeaningfulParts.protocol property + +Signature: + +```typescript +protocol?: string | null; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.query.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.query.md new file mode 100644 index 0000000000000..a9541efe0882a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.query.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) > [query](./kibana-plugin-core-public.urlmeaningfulparts.query.md) + +## URLMeaningfulParts.query property + +Signature: + +```typescript +query: ParsedQuery; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.slashes.md b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.slashes.md new file mode 100644 index 0000000000000..cb28a25f9e162 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.urlmeaningfulparts.slashes.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [URLMeaningfulParts](./kibana-plugin-core-public.urlmeaningfulparts.md) > [slashes](./kibana-plugin-core-public.urlmeaningfulparts.slashes.md) + +## URLMeaningfulParts.slashes property + +Signature: + +```typescript +slashes?: boolean | null; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.assertnever.md b/docs/development/core/server/kibana-plugin-core-server.assertnever.md new file mode 100644 index 0000000000000..c13c88df9b9bf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.assertnever.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [assertNever](./kibana-plugin-core-server.assertnever.md) + +## assertNever() function + +Can be used in switch statements to ensure we perform exhaustive checks, see https://www.typescriptlang.org/docs/handbook/advanced-types.html\#exhaustiveness-checking + +Signature: + +```typescript +export declare function assertNever(x: never): never; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| x | never | | + +Returns: + +`never` + diff --git a/docs/development/core/server/kibana-plugin-core-server.deepfreeze.md b/docs/development/core/server/kibana-plugin-core-server.deepfreeze.md new file mode 100644 index 0000000000000..946050bff0585 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.deepfreeze.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [deepFreeze](./kibana-plugin-core-server.deepfreeze.md) + +## deepFreeze() function + +Apply Object.freeze to a value recursively and convert the return type to Readonly variant recursively + +Signature: + +```typescript +export declare function deepFreeze(object: T): RecursiveReadonly; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| object | T | | + +Returns: + +`RecursiveReadonly` + diff --git a/docs/development/core/server/kibana-plugin-core-server.freezable.md b/docs/development/core/server/kibana-plugin-core-server.freezable.md new file mode 100644 index 0000000000000..32ba89e8370c1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.freezable.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [Freezable](./kibana-plugin-core-server.freezable.md) + +## Freezable type + + +Signature: + +```typescript +export declare type Freezable = { + [k: string]: any; +} | any[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.getflattenedobject.md b/docs/development/core/server/kibana-plugin-core-server.getflattenedobject.md new file mode 100644 index 0000000000000..2e7850ca579f6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.getflattenedobject.md @@ -0,0 +1,30 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [getFlattenedObject](./kibana-plugin-core-server.getflattenedobject.md) + +## getFlattenedObject() function + +Flattens a deeply nested object to a map of dot-separated paths pointing to all primitive values \*\*and arrays\*\* from `rootValue`. + +example: getFlattenedObject({ a: { b: 1, c: \[2,3\] } }) // => { 'a.b': 1, 'a.c': \[2,3\] } + +Signature: + +```typescript +export declare function getFlattenedObject(rootValue: Record): { + [key: string]: any; +}; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| rootValue | Record<string, any> | | + +Returns: + +`{ + [key: string]: any; +}` + diff --git a/docs/development/core/server/kibana-plugin-core-server.isrelativeurl.md b/docs/development/core/server/kibana-plugin-core-server.isrelativeurl.md new file mode 100644 index 0000000000000..bff9eb05419be --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.isrelativeurl.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [isRelativeUrl](./kibana-plugin-core-server.isrelativeurl.md) + +## isRelativeUrl() function + +Determine if a url is relative. Any url including a protocol, hostname, or port is not considered relative. This means that absolute \*paths\* are considered to be relative \*urls\* + +Signature: + +```typescript +export declare function isRelativeUrl(candidatePath: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| candidatePath | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index a91a5bec988b7..14e01fda3d287 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -41,8 +41,13 @@ The plugin integrates with the core system via lifecycle events: `setup` | Function | Description | | --- | --- | +| [assertNever(x)](./kibana-plugin-core-server.assertnever.md) | Can be used in switch statements to ensure we perform exhaustive checks, see https://www.typescriptlang.org/docs/handbook/advanced-types.html\#exhaustiveness-checking | +| [deepFreeze(object)](./kibana-plugin-core-server.deepfreeze.md) | Apply Object.freeze to a value recursively and convert the return type to Readonly variant recursively | | [exportSavedObjectsToStream({ types, objects, search, savedObjectsClient, exportSizeLimit, includeReferencesDeep, excludeExportDetails, namespace, })](./kibana-plugin-core-server.exportsavedobjectstostream.md) | Generates sorted saved object stream to be used for export. See the [options](./kibana-plugin-core-server.savedobjectsexportoptions.md) for more detailed information. | +| [getFlattenedObject(rootValue)](./kibana-plugin-core-server.getflattenedobject.md) | Flattens a deeply nested object to a map of dot-separated paths pointing to all primitive values \*\*and arrays\*\* from rootValue.example: getFlattenedObject({ a: { b: 1, c: \[2,3\] } }) // => { 'a.b': 1, 'a.c': \[2,3\] } | | [importSavedObjectsFromStream({ readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, })](./kibana-plugin-core-server.importsavedobjectsfromstream.md) | Import saved objects from given stream. See the [options](./kibana-plugin-core-server.savedobjectsimportoptions.md) for more detailed information. | +| [isRelativeUrl(candidatePath)](./kibana-plugin-core-server.isrelativeurl.md) | Determine if a url is relative. Any url including a protocol, hostname, or port is not considered relative. This means that absolute \*paths\* are considered to be relative \*urls\* | +| [modifyUrl(url, urlModifier)](./kibana-plugin-core-server.modifyurl.md) | Takes a URL and a function that takes the meaningful parts of the URL as a key-value object, modifies some or all of the parts, and returns the modified parts formatted again as a url.Url Parts sent: - protocol - slashes (does the url have the //) - auth - hostname (just the name of the host, no port or auth information) - port - pathname (the path after the hostname, no query or hash, starts with a slash if there was a path) - query (always an object, even when no query on original url) - hashWhy? - The default url library in node produces several conflicting properties on the "parsed" output. Modifying any of these might lead to the modifications being ignored (depending on which property was modified) - It's not always clear whether to use path/pathname, host/hostname, so this tries to add helpful constraints | | [resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, })](./kibana-plugin-core-server.resolvesavedobjectsimporterrors.md) | Resolve and return saved object import errors. See the [options](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) for more detailed informations. | ## Interfaces @@ -186,6 +191,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) | UiSettings parameters defined by the plugins. | | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) | | | [UiSettingsServiceStart](./kibana-plugin-core-server.uisettingsservicestart.md) | | +| [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) | We define our own typings because the current version of @types/node declares properties to be optional "hostname?: string". Although, parse call returns "hostname: null \| string". | | [UserProvidedValues](./kibana-plugin-core-server.userprovidedvalues.md) | Describes the values explicitly set by user. | | [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) | APIs to access the application's instance uuid. | @@ -212,6 +218,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ConfigPath](./kibana-plugin-core-server.configpath.md) | | | [DestructiveRouteMethod](./kibana-plugin-core-server.destructiveroutemethod.md) | Set of HTTP methods changing the state of the server. | | [ElasticsearchClientConfig](./kibana-plugin-core-server.elasticsearchclientconfig.md) | | +| [Freezable](./kibana-plugin-core-server.freezable.md) | | | [GetAuthHeaders](./kibana-plugin-core-server.getauthheaders.md) | Get headers to authenticate a user against Elasticsearch. | | [GetAuthState](./kibana-plugin-core-server.getauthstate.md) | Gets authentication state for a request. Returned by auth interceptor. | | [HandlerContextType](./kibana-plugin-core-server.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-core-server.handlerfunction.md) to represent the type of the context. | diff --git a/docs/development/core/server/kibana-plugin-core-server.modifyurl.md b/docs/development/core/server/kibana-plugin-core-server.modifyurl.md new file mode 100644 index 0000000000000..fc0bc354a3ca3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.modifyurl.md @@ -0,0 +1,31 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [modifyUrl](./kibana-plugin-core-server.modifyurl.md) + +## modifyUrl() function + +Takes a URL and a function that takes the meaningful parts of the URL as a key-value object, modifies some or all of the parts, and returns the modified parts formatted again as a url. + +Url Parts sent: - protocol - slashes (does the url have the //) - auth - hostname (just the name of the host, no port or auth information) - port - pathname (the path after the hostname, no query or hash, starts with a slash if there was a path) - query (always an object, even when no query on original url) - hash + +Why? - The default url library in node produces several conflicting properties on the "parsed" output. Modifying any of these might lead to the modifications being ignored (depending on which property was modified) - It's not always clear whether to use path/pathname, host/hostname, so this tries to add helpful constraints + +Signature: + +```typescript +export declare function modifyUrl(url: string, urlModifier: (urlParts: URLMeaningfulParts) => Partial | void): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| url | string | | +| urlModifier | (urlParts: URLMeaningfulParts) => Partial<URLMeaningfulParts> | void | | + +Returns: + +`string` + +The modified and reformatted url + diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.auth.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.auth.md new file mode 100644 index 0000000000000..0422738669a70 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.auth.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) > [auth](./kibana-plugin-core-server.urlmeaningfulparts.auth.md) + +## URLMeaningfulParts.auth property + +Signature: + +```typescript +auth?: string | null; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.hash.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.hash.md new file mode 100644 index 0000000000000..13a3f4a9c95c8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.hash.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) > [hash](./kibana-plugin-core-server.urlmeaningfulparts.hash.md) + +## URLMeaningfulParts.hash property + +Signature: + +```typescript +hash?: string | null; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.hostname.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.hostname.md new file mode 100644 index 0000000000000..6631f6f6744c5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.hostname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) > [hostname](./kibana-plugin-core-server.urlmeaningfulparts.hostname.md) + +## URLMeaningfulParts.hostname property + +Signature: + +```typescript +hostname?: string | null; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.md new file mode 100644 index 0000000000000..257f7b4b634ab --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) + +## URLMeaningfulParts interface + +We define our own typings because the current version of @types/node declares properties to be optional "hostname?: string". Although, parse call returns "hostname: null \| string". + +Signature: + +```typescript +export interface URLMeaningfulParts +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [auth](./kibana-plugin-core-server.urlmeaningfulparts.auth.md) | string | null | | +| [hash](./kibana-plugin-core-server.urlmeaningfulparts.hash.md) | string | null | | +| [hostname](./kibana-plugin-core-server.urlmeaningfulparts.hostname.md) | string | null | | +| [pathname](./kibana-plugin-core-server.urlmeaningfulparts.pathname.md) | string | null | | +| [port](./kibana-plugin-core-server.urlmeaningfulparts.port.md) | string | null | | +| [protocol](./kibana-plugin-core-server.urlmeaningfulparts.protocol.md) | string | null | | +| [query](./kibana-plugin-core-server.urlmeaningfulparts.query.md) | ParsedQuery | | +| [slashes](./kibana-plugin-core-server.urlmeaningfulparts.slashes.md) | boolean | null | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.pathname.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.pathname.md new file mode 100644 index 0000000000000..8fee8c8e146ca --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.pathname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) > [pathname](./kibana-plugin-core-server.urlmeaningfulparts.pathname.md) + +## URLMeaningfulParts.pathname property + +Signature: + +```typescript +pathname?: string | null; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.port.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.port.md new file mode 100644 index 0000000000000..dcf3517d92ba2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.port.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) > [port](./kibana-plugin-core-server.urlmeaningfulparts.port.md) + +## URLMeaningfulParts.port property + +Signature: + +```typescript +port?: string | null; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.protocol.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.protocol.md new file mode 100644 index 0000000000000..914dcd4e8a8a5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.protocol.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) > [protocol](./kibana-plugin-core-server.urlmeaningfulparts.protocol.md) + +## URLMeaningfulParts.protocol property + +Signature: + +```typescript +protocol?: string | null; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.query.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.query.md new file mode 100644 index 0000000000000..358adcfd3d180 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.query.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) > [query](./kibana-plugin-core-server.urlmeaningfulparts.query.md) + +## URLMeaningfulParts.query property + +Signature: + +```typescript +query: ParsedQuery; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.slashes.md b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.slashes.md new file mode 100644 index 0000000000000..d5b598167f2f2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.urlmeaningfulparts.slashes.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) > [slashes](./kibana-plugin-core-server.urlmeaningfulparts.slashes.md) + +## URLMeaningfulParts.slashes property + +Signature: + +```typescript +slashes?: boolean | null; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.__spec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.__spec.md deleted file mode 100644 index 43ff9a930b974..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.__spec.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [$$spec](./kibana-plugin-plugins-data-public.field.__spec.md) - -## Field.$$spec property - -Signature: - -```typescript -$$spec: FieldSpec; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.aggregatable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.aggregatable.md deleted file mode 100644 index fcfd7d73c8b0c..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.aggregatable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [aggregatable](./kibana-plugin-plugins-data-public.field.aggregatable.md) - -## Field.aggregatable property - -Signature: - -```typescript -aggregatable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.conflictdescriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.conflictdescriptions.md deleted file mode 100644 index 21b6917c4aad4..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.conflictdescriptions.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [conflictDescriptions](./kibana-plugin-plugins-data-public.field.conflictdescriptions.md) - -## Field.conflictDescriptions property - -Signature: - -```typescript -conflictDescriptions?: Record; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.count.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.count.md deleted file mode 100644 index 4f51d88a3046e..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.count.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [count](./kibana-plugin-plugins-data-public.field.count.md) - -## Field.count property - -Signature: - -```typescript -count?: number; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.displayname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.displayname.md deleted file mode 100644 index 0846a7595cf90..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.displayname.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [displayName](./kibana-plugin-plugins-data-public.field.displayname.md) - -## Field.displayName property - -Signature: - -```typescript -displayName?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.estypes.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.estypes.md deleted file mode 100644 index efe1bceb43361..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.estypes.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [esTypes](./kibana-plugin-plugins-data-public.field.estypes.md) - -## Field.esTypes property - -Signature: - -```typescript -esTypes?: string[]; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.filterable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.filterable.md deleted file mode 100644 index fd7be589e87a7..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.filterable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [filterable](./kibana-plugin-plugins-data-public.field.filterable.md) - -## Field.filterable property - -Signature: - -```typescript -filterable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.format.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.format.md deleted file mode 100644 index 431e043d1fecc..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.format.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [format](./kibana-plugin-plugins-data-public.field.format.md) - -## Field.format property - -Signature: - -```typescript -format: any; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.indexpattern.md deleted file mode 100644 index 59420747e0958..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.indexpattern.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [indexPattern](./kibana-plugin-plugins-data-public.field.indexpattern.md) - -## Field.indexPattern property - -Signature: - -```typescript -indexPattern?: IndexPattern; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.lang.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.lang.md deleted file mode 100644 index d51857090356f..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.lang.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [lang](./kibana-plugin-plugins-data-public.field.lang.md) - -## Field.lang property - -Signature: - -```typescript -lang?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.md deleted file mode 100644 index 692f34e0d81df..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.md +++ /dev/null @@ -1,41 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) - -## Field class - -Signature: - -```typescript -export declare class Field implements IFieldType -``` - -## Constructors - -| Constructor | Modifiers | Description | -| --- | --- | --- | -| [(constructor)(indexPattern, spec, shortDotsEnable, { fieldFormats, toastNotifications })](./kibana-plugin-plugins-data-public.field._constructor_.md) | | Constructs a new instance of the Field class | - -## Properties - -| Property | Modifiers | Type | Description | -| --- | --- | --- | --- | -| [$$spec](./kibana-plugin-plugins-data-public.field.__spec.md) | | FieldSpec | | -| [aggregatable](./kibana-plugin-plugins-data-public.field.aggregatable.md) | | boolean | | -| [conflictDescriptions](./kibana-plugin-plugins-data-public.field.conflictdescriptions.md) | | Record<string, string[]> | | -| [count](./kibana-plugin-plugins-data-public.field.count.md) | | number | | -| [displayName](./kibana-plugin-plugins-data-public.field.displayname.md) | | string | | -| [esTypes](./kibana-plugin-plugins-data-public.field.estypes.md) | | string[] | | -| [filterable](./kibana-plugin-plugins-data-public.field.filterable.md) | | boolean | | -| [format](./kibana-plugin-plugins-data-public.field.format.md) | | any | | -| [indexPattern](./kibana-plugin-plugins-data-public.field.indexpattern.md) | | IndexPattern | | -| [lang](./kibana-plugin-plugins-data-public.field.lang.md) | | string | | -| [name](./kibana-plugin-plugins-data-public.field.name.md) | | string | | -| [script](./kibana-plugin-plugins-data-public.field.script.md) | | string | | -| [scripted](./kibana-plugin-plugins-data-public.field.scripted.md) | | boolean | | -| [searchable](./kibana-plugin-plugins-data-public.field.searchable.md) | | boolean | | -| [sortable](./kibana-plugin-plugins-data-public.field.sortable.md) | | boolean | | -| [subType](./kibana-plugin-plugins-data-public.field.subtype.md) | | IFieldSubType | | -| [type](./kibana-plugin-plugins-data-public.field.type.md) | | string | | -| [visualizable](./kibana-plugin-plugins-data-public.field.visualizable.md) | | boolean | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.name.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.name.md deleted file mode 100644 index d2a9b9b86aefc..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.name.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [name](./kibana-plugin-plugins-data-public.field.name.md) - -## Field.name property - -Signature: - -```typescript -name: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.script.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.script.md deleted file mode 100644 index 676ff9bdfc35a..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.script.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [script](./kibana-plugin-plugins-data-public.field.script.md) - -## Field.script property - -Signature: - -```typescript -script?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.scripted.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.scripted.md deleted file mode 100644 index 1f6c8105e3f61..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.scripted.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [scripted](./kibana-plugin-plugins-data-public.field.scripted.md) - -## Field.scripted property - -Signature: - -```typescript -scripted?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.searchable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.searchable.md deleted file mode 100644 index 186d344f50378..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.searchable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [searchable](./kibana-plugin-plugins-data-public.field.searchable.md) - -## Field.searchable property - -Signature: - -```typescript -searchable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.sortable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.sortable.md deleted file mode 100644 index 0cd4b14d0e1e5..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.sortable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [sortable](./kibana-plugin-plugins-data-public.field.sortable.md) - -## Field.sortable property - -Signature: - -```typescript -sortable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.subtype.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.subtype.md deleted file mode 100644 index bef3b2131fa47..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.subtype.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [subType](./kibana-plugin-plugins-data-public.field.subtype.md) - -## Field.subType property - -Signature: - -```typescript -subType?: IFieldSubType; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.type.md deleted file mode 100644 index 490615edcf097..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.type.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [type](./kibana-plugin-plugins-data-public.field.type.md) - -## Field.type property - -Signature: - -```typescript -type: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.visualizable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.visualizable.md deleted file mode 100644 index f32a5c456dc5d..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.visualizable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [visualizable](./kibana-plugin-plugins-data-public.field.visualizable.md) - -## Field.visualizable property - -Signature: - -```typescript -visualizable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.__spec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.__spec.md new file mode 100644 index 0000000000000..f52a3324af36f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.__spec.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [$$spec](./kibana-plugin-plugins-data-public.indexpatternfield.__spec.md) + +## IndexPatternField.$$spec property + +Signature: + +```typescript +$$spec: FieldSpec; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md similarity index 73% rename from docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field._constructor_.md rename to docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md index faa793d4e9dfd..8ee9acc684fb1 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md @@ -1,8 +1,8 @@ -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [(constructor)](./kibana-plugin-plugins-data-public.field._constructor_.md) +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [(constructor)](./kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md) -## Field.(constructor) +## IndexPatternField.(constructor) Constructs a new instance of the `Field` class diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md new file mode 100644 index 0000000000000..267c8f786b5dd --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [aggregatable](./kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md) + +## IndexPatternField.aggregatable property + +Signature: + +```typescript +aggregatable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md new file mode 100644 index 0000000000000..ca2552aeb1b42 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [conflictDescriptions](./kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md) + +## IndexPatternField.conflictDescriptions property + +Signature: + +```typescript +conflictDescriptions?: Record; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.count.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.count.md new file mode 100644 index 0000000000000..8e848276f21c4 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.count.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [count](./kibana-plugin-plugins-data-public.indexpatternfield.count.md) + +## IndexPatternField.count property + +Signature: + +```typescript +count?: number; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.displayname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.displayname.md new file mode 100644 index 0000000000000..ed9630f92fc97 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.displayname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [displayName](./kibana-plugin-plugins-data-public.indexpatternfield.displayname.md) + +## IndexPatternField.displayName property + +Signature: + +```typescript +displayName?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.estypes.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.estypes.md new file mode 100644 index 0000000000000..dec74df099d43 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.estypes.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [esTypes](./kibana-plugin-plugins-data-public.indexpatternfield.estypes.md) + +## IndexPatternField.esTypes property + +Signature: + +```typescript +esTypes?: string[]; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.filterable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.filterable.md new file mode 100644 index 0000000000000..4290c4a2f86b3 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.filterable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [filterable](./kibana-plugin-plugins-data-public.indexpatternfield.filterable.md) + +## IndexPatternField.filterable property + +Signature: + +```typescript +filterable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.format.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.format.md new file mode 100644 index 0000000000000..d5df8ed628cb0 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.format.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [format](./kibana-plugin-plugins-data-public.indexpatternfield.format.md) + +## IndexPatternField.format property + +Signature: + +```typescript +format: any; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md new file mode 100644 index 0000000000000..d1a1ee0905c6e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [indexPattern](./kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md) + +## IndexPatternField.indexPattern property + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.lang.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.lang.md new file mode 100644 index 0000000000000..f731be8f613cf --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.lang.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [lang](./kibana-plugin-plugins-data-public.indexpatternfield.lang.md) + +## IndexPatternField.lang property + +Signature: + +```typescript +lang?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md new file mode 100644 index 0000000000000..a62cee7b654fe --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md @@ -0,0 +1,41 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) + +## IndexPatternField class + +Signature: + +```typescript +export declare class Field implements IFieldType +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(indexPattern, spec, shortDotsEnable, { fieldFormats, toastNotifications })](./kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md) | | Constructs a new instance of the Field class | + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [$$spec](./kibana-plugin-plugins-data-public.indexpatternfield.__spec.md) | | FieldSpec | | +| [aggregatable](./kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md) | | boolean | | +| [conflictDescriptions](./kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md) | | Record<string, string[]> | | +| [count](./kibana-plugin-plugins-data-public.indexpatternfield.count.md) | | number | | +| [displayName](./kibana-plugin-plugins-data-public.indexpatternfield.displayname.md) | | string | | +| [esTypes](./kibana-plugin-plugins-data-public.indexpatternfield.estypes.md) | | string[] | | +| [filterable](./kibana-plugin-plugins-data-public.indexpatternfield.filterable.md) | | boolean | | +| [format](./kibana-plugin-plugins-data-public.indexpatternfield.format.md) | | any | | +| [indexPattern](./kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md) | | IndexPattern | | +| [lang](./kibana-plugin-plugins-data-public.indexpatternfield.lang.md) | | string | | +| [name](./kibana-plugin-plugins-data-public.indexpatternfield.name.md) | | string | | +| [script](./kibana-plugin-plugins-data-public.indexpatternfield.script.md) | | string | | +| [scripted](./kibana-plugin-plugins-data-public.indexpatternfield.scripted.md) | | boolean | | +| [searchable](./kibana-plugin-plugins-data-public.indexpatternfield.searchable.md) | | boolean | | +| [sortable](./kibana-plugin-plugins-data-public.indexpatternfield.sortable.md) | | boolean | | +| [subType](./kibana-plugin-plugins-data-public.indexpatternfield.subtype.md) | | IFieldSubType | | +| [type](./kibana-plugin-plugins-data-public.indexpatternfield.type.md) | | string | | +| [visualizable](./kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md) | | boolean | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.name.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.name.md new file mode 100644 index 0000000000000..cb24621e73209 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.name.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [name](./kibana-plugin-plugins-data-public.indexpatternfield.name.md) + +## IndexPatternField.name property + +Signature: + +```typescript +name: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.script.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.script.md new file mode 100644 index 0000000000000..132ba25a47637 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.script.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [script](./kibana-plugin-plugins-data-public.indexpatternfield.script.md) + +## IndexPatternField.script property + +Signature: + +```typescript +script?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.scripted.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.scripted.md new file mode 100644 index 0000000000000..1dd6bc865a75d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.scripted.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [scripted](./kibana-plugin-plugins-data-public.indexpatternfield.scripted.md) + +## IndexPatternField.scripted property + +Signature: + +```typescript +scripted?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.searchable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.searchable.md new file mode 100644 index 0000000000000..42f984d851435 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.searchable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [searchable](./kibana-plugin-plugins-data-public.indexpatternfield.searchable.md) + +## IndexPatternField.searchable property + +Signature: + +```typescript +searchable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.sortable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.sortable.md new file mode 100644 index 0000000000000..72d225185140b --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.sortable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [sortable](./kibana-plugin-plugins-data-public.indexpatternfield.sortable.md) + +## IndexPatternField.sortable property + +Signature: + +```typescript +sortable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.subtype.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.subtype.md new file mode 100644 index 0000000000000..2d807f8a5739c --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.subtype.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [subType](./kibana-plugin-plugins-data-public.indexpatternfield.subtype.md) + +## IndexPatternField.subType property + +Signature: + +```typescript +subType?: IFieldSubType; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.type.md new file mode 100644 index 0000000000000..c8483c9b83c9a --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [type](./kibana-plugin-plugins-data-public.indexpatternfield.type.md) + +## IndexPatternField.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md new file mode 100644 index 0000000000000..dd661ae779c11 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [visualizable](./kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md) + +## IndexPatternField.visualizable property + +Signature: + +```typescript +visualizable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index f1024c0954251..8b58957b9044a 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -9,10 +9,10 @@ | Class | Description | | --- | --- | | [AggParamType](./kibana-plugin-plugins-data-public.aggparamtype.md) | | -| [Field](./kibana-plugin-plugins-data-public.field.md) | | | [FieldFormat](./kibana-plugin-plugins-data-public.fieldformat.md) | | | [FilterManager](./kibana-plugin-plugins-data-public.filtermanager.md) | | | [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) | | +| [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) | | | [IndexPatternSelect](./kibana-plugin-plugins-data-public.indexpatternselect.md) | | | [OptionedParamType](./kibana-plugin-plugins-data-public.optionedparamtype.md) | | | [Plugin](./kibana-plugin-plugins-data-public.plugin.md) | | diff --git a/docs/user/security/securing-kibana.asciidoc b/docs/user/security/securing-kibana.asciidoc index 24aacd6a47626..f4178bacb111e 100644 --- a/docs/user/security/securing-kibana.asciidoc +++ b/docs/user/security/securing-kibana.asciidoc @@ -31,14 +31,14 @@ file: [source,yaml] ----------------------------------------------- -elasticsearch.username: "kibana" +elasticsearch.username: "kibana_system" elasticsearch.password: "kibanapassword" ----------------------------------------------- The {kib} server submits requests as this user to access the cluster monitoring APIs and the `.kibana` index. The server does _not_ need access to user indices. -The password for the built-in `kibana` user is typically set as part of the +The password for the built-in `kibana_system` user is typically set as part of the {security} configuration process on {es}. For more information, see {ref}/built-in-users.html[Built-in users]. -- diff --git a/docs/visualize/timelion.asciidoc b/docs/visualize/timelion.asciidoc index a7520227977bc..852c3e1ecdeca 100644 --- a/docs/visualize/timelion.asciidoc +++ b/docs/visualize/timelion.asciidoc @@ -50,10 +50,10 @@ To compare the two data sets, add another series with data from the previous hou .es(index=metricbeat-*, timefield='@timestamp', metric='avg:system.cpu.user.pct'), - .es(offset=-1h, <1> - index=metricbeat-*, - timefield='@timestamp', - metric='avg:system.cpu.user.pct') +.es(offset=-1h, <1> + index=metricbeat-*, + timefield='@timestamp', + metric='avg:system.cpu.user.pct') ---------------------------------- <1> `offset` offsets the data retrieval by a date expression. In this example, `-1h` offsets the data back by one hour. @@ -119,11 +119,11 @@ To differentiate between the current hour data and the last hour data, change th metric='avg:system.cpu.user.pct') .label('last hour') .lines(fill=1,width=0.5), <1> - .es(index=metricbeat-*, - timefield='@timestamp', - metric='avg:system.cpu.user.pct') - .label('current hour') - .title('CPU usage over time') +.es(index=metricbeat-*, + timefield='@timestamp', + metric='avg:system.cpu.user.pct') + .label('current hour') + .title('CPU usage over time') ---------------------------------- <1> `.lines()` changes the appearance of the chart lines. In this example, `.lines(fill=1,width=0.5)` sets the fill level to `1`, and the border width to `0.5`. @@ -169,7 +169,20 @@ Change the position and style of the legend: [source,text] ---------------------------------- -.es(offset=-1h,index=metricbeat-*, timefield='@timestamp', metric='avg:system.cpu.user.pct').label('last hour').lines(fill=1,width=0.5).color(gray), .es(index=metricbeat-*, timefield='@timestamp', metric='avg:system.cpu.user.pct').label('current hour').title('CPU usage over time').color(#1E90FF).legend(columns=2, position=nw) <1> +.es(offset=-1h, + index=metricbeat-*, + timefield='@timestamp', + metric='avg:system.cpu.user.pct') + .label('last hour') + .lines(fill=1,width=0.5) + .color(gray), +.es(index=metricbeat-*, + timefield='@timestamp', + metric='avg:system.cpu.user.pct') + .label('current hour') + .title('CPU usage over time') + .color(#1E90FF) + .legend(columns=2, position=nw) <1> ---------------------------------- <1> `.legend()` sets the position and style of the legend. In this example, `.legend(columns=2, position=nw)` places the legend in the north west position of the visualization with two columns. @@ -192,7 +205,9 @@ To start tracking the inbound and outbound network traffic, enter the following [source,text] ---------------------------------- -.es(index=metricbeat*, timefield=@timestamp, metric=max:system.network.in.bytes) +.es(index=metricbeat*, + timefield=@timestamp, + metric=max:system.network.in.bytes) ---------------------------------- [role="screenshot"] @@ -207,7 +222,10 @@ Change how the data is displayed so that you can easily monitor the inbound traf [source,text] ---------------------------------- -.es(index=metricbeat*, timefield=@timestamp, metric=max:system.network.in.bytes).derivative() <1> +.es(index=metricbeat*, + timefield=@timestamp, + metric=max:system.network.in.bytes) + .derivative() <1> ---------------------------------- <1> `.derivative` plots the change in values over time. @@ -220,7 +238,15 @@ Add a similar calculation for outbound traffic: [source,text] ---------------------------------- -.es(index=metricbeat*, timefield=@timestamp, metric=max:system.network.in.bytes).derivative(), .es(index=metricbeat*, timefield=@timestamp, metric=max:system.network.out.bytes).derivative().multiply(-1) <1> +.es(index=metricbeat*, + timefield=@timestamp, + metric=max:system.network.in.bytes) + .derivative(), +.es(index=metricbeat*, + timefield=@timestamp, + metric=max:system.network.out.bytes) + .derivative() + .multiply(-1) <1> ---------------------------------- <1> `.multiply()` multiplies the data series by a number, the result of a data series, or a list of data series. For this example, `.multiply(-1)` converts the outbound network traffic to a negative value since the outbound network traffic is leaving your machine. @@ -237,7 +263,17 @@ To make the visualization easier to analyze, change the data metric from bytes t [source,text] ---------------------------------- -.es(index=metricbeat*, timefield=@timestamp, metric=max:system.network.in.bytes).derivative().divide(1048576), .es(index=metricbeat*, timefield=@timestamp, metric=max:system.network.out.bytes).derivative().multiply(-1).divide(1048576) <1> +.es(index=metricbeat*, + timefield=@timestamp, + metric=max:system.network.in.bytes) + .derivative() + .divide(1048576), +.es(index=metricbeat*, + timefield=@timestamp, + metric=max:system.network.out.bytes) + .derivative() + .multiply(-1) + .divide(1048576) <1> ---------------------------------- <1> `.divide()` accepts the same input as `.multiply()`, then divides the data series by the defined divisor. @@ -271,8 +307,8 @@ Customize and format the visualization using functions: .divide(1048576) .lines(fill=2, width=1) <3> .color(blue) <4> - .label("Outbound traffic") - .legend(columns=2, position=nw) <5> + .label("Outbound traffic") + .legend(columns=2, position=nw) <5> ---------------------------------- <1> `.label()` adds custom labels to the visualization. @@ -309,7 +345,9 @@ To chart the maximum value of `system.memory.actual.used.bytes`, enter the follo [source,text] ---------------------------------- -.es(index=metricbeat-*, timefield='@timestamp', metric='max:system.memory.actual.used.bytes') +.es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes') ---------------------------------- [role="screenshot"] @@ -338,17 +376,17 @@ To track the amount of memory used, create two thresholds: null) .label('warning') .color('#FFCC11'), - .es(index=metricbeat-*, - timefield='@timestamp', - metric='max:system.memory.actual.used.bytes') - .if(gt, - 11375000000, - .es(index=metricbeat-*, - timefield='@timestamp', - metric='max:system.memory.actual.used.bytes'), - null) - .label('severe') - .color('red') +.es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes') + .if(gt, + 11375000000, + .es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes'), + null) + .label('severe') + .color('red') ---------------------------------- <1> Timelion conditional logic for the _greater than_ operator. In this example, the warning threshold is 11.3GB (`11300000000`), and the severe threshold is 11.375GB (`11375000000`). If the threshold values are too high or low for your machine, adjust the values accordingly. @@ -366,7 +404,33 @@ To determine the trend, create a new data series: [source,text] ---------------------------------- -.es(index=metricbeat-*, timefield='@timestamp', metric='max:system.memory.actual.used.bytes'), .es(index=metricbeat-*, timefield='@timestamp', metric='max:system.memory.actual.used.bytes').if(gt,11300000000,.es(index=metricbeat-*, timefield='@timestamp', metric='max:system.memory.actual.used.bytes'),null).label('warning').color('#FFCC11'), .es(index=metricbeat-*, timefield='@timestamp', metric='max:system.memory.actual.used.bytes').if(gt,11375000000,.es(index=metricbeat-*, timefield='@timestamp', metric='max:system.memory.actual.used.bytes'),null).label('severe').color('red'), .es(index=metricbeat-*, timefield='@timestamp', metric='max:system.memory.actual.used.bytes').mvavg(10) <1> +.es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes'), +.es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes') + .if(gt,11300000000, + .es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes'), + null) + .label('warning') + .color('#FFCC11'), +.es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes') + .if(gt,11375000000, + .es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes'), + null). + label('severe') + .color('red'), +.es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes') + .mvavg(10) <1> ---------------------------------- <1> `mvavg()` calculates the moving average over a specified period of time. In this example, `.mvavg(10)` creates a moving average with a window of 10 data points. @@ -396,30 +460,30 @@ Customize and format the visualization using functions: .es(index=metricbeat-*, timefield='@timestamp', metric='max:system.memory.actual.used.bytes'), - null) - .label('warning') - .color('#FFCC11') <3> - .lines(width=5), <4> - .es(index=metricbeat-*, - timefield='@timestamp', - metric='max:system.memory.actual.used.bytes') - .if(gt, - 11375000000, - .es(index=metricbeat-*, - timefield='@timestamp', - metric='max:system.memory.actual.used.bytes'), - null) - .label('severe') - .color('red') - .lines(width=5), + null) + .label('warning') + .color('#FFCC11') <3> + .lines(width=5), <4> +.es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes') + .if(gt, + 11375000000, .es(index=metricbeat-*, timefield='@timestamp', - metric='max:system.memory.actual.used.bytes') - .mvavg(10) - .label('mvavg') - .lines(width=2) - .color(#5E5E5E) - .legend(columns=4, position=nw) <5> + metric='max:system.memory.actual.used.bytes'), + null) + .label('severe') + .color('red') + .lines(width=5), +.es(index=metricbeat-*, + timefield='@timestamp', + metric='max:system.memory.actual.used.bytes') + .mvavg(10) + .label('mvavg') + .lines(width=2) + .color(#5E5E5E) + .legend(columns=4, position=nw) <5> ---------------------------------- <1> `.label()` adds custom labels to the visualization. diff --git a/examples/alerting_example/public/application.tsx b/examples/alerting_example/public/application.tsx index 6ff5a7d0880b8..23e9d19441002 100644 --- a/examples/alerting_example/public/application.tsx +++ b/examples/alerting_example/public/application.tsx @@ -27,6 +27,7 @@ import { IUiSettingsClient, DocLinksStart, ToastsSetup, + ApplicationStart, } from '../../../src/core/public'; import { DataPublicPluginStart } from '../../../src/plugins/data/public'; import { ChartsPluginStart } from '../../../src/plugins/charts/public'; @@ -48,6 +49,7 @@ export interface AlertingExampleComponentParams { uiSettings: IUiSettingsClient; docLinks: DocLinksStart; toastNotifications: ToastsSetup; + capabilities: ApplicationStart['capabilities']; } const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { @@ -102,6 +104,7 @@ export const renderApp = ( http={http} uiSettings={uiSettings} docLinks={docLinks} + capabilities={application.capabilities} {...deps} />, element diff --git a/examples/alerting_example/public/components/create_alert.tsx b/examples/alerting_example/public/components/create_alert.tsx index 0541e0b18a2e1..a8e1f06cb3914 100644 --- a/examples/alerting_example/public/components/create_alert.tsx +++ b/examples/alerting_example/public/components/create_alert.tsx @@ -36,6 +36,7 @@ export const CreateAlert = ({ docLinks, data, toastNotifications, + capabilities, }: AlertingExampleComponentParams) => { const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); @@ -60,6 +61,7 @@ export const CreateAlert = ({ docLinks, charts, dataFieldsFormats: data.fieldFormats, + capabilities, }} > { mockClient.security.getUser.mockImplementation(() => ({ body: { - kibana: { + kibana_system: { metadata: { _reserved: true, }, @@ -138,7 +138,7 @@ describe('setPasswords', () => { })); await nativeRealm.setPasswords({ - 'password.kibana': 'bar', + 'password.kibana_system': 'bar', }); expect(mockClient.security.changePassword.mock.calls).toMatchInlineSnapshot(` @@ -149,7 +149,7 @@ Array [ "password": "bar", }, "refresh": "wait_for", - "username": "kibana", + "username": "kibana_system", }, ], Array [ @@ -188,7 +188,7 @@ describe('getReservedUsers', () => { it('returns array of reserved usernames', async () => { mockClient.security.getUser.mockImplementation(() => ({ body: { - kibana: { + kibana_system: { metadata: { _reserved: true, }, @@ -206,17 +206,17 @@ describe('getReservedUsers', () => { }, })); - expect(await nativeRealm.getReservedUsers()).toEqual(['kibana', 'logstash_system']); + expect(await nativeRealm.getReservedUsers()).toEqual(['kibana_system', 'logstash_system']); }); }); describe('setPassword', () => { it('sets password for provided user', async () => { - await nativeRealm.setPassword('kibana', 'foo'); + await nativeRealm.setPassword('kibana_system', 'foo'); expect(mockClient.security.changePassword).toHaveBeenCalledWith({ body: { password: 'foo' }, refresh: 'wait_for', - username: 'kibana', + username: 'kibana_system', }); }); @@ -226,7 +226,7 @@ describe('setPassword', () => { }); await expect( - nativeRealm.setPassword('kibana', 'foo') + nativeRealm.setPassword('kibana_system', 'foo') ).rejects.toThrowErrorMatchingInlineSnapshot(`"SomeError"`); }); }); diff --git a/packages/kbn-ui-framework/package.json b/packages/kbn-ui-framework/package.json index 5ea031595d1d4..47ed69bc95697 100644 --- a/packages/kbn-ui-framework/package.json +++ b/packages/kbn-ui-framework/package.json @@ -50,7 +50,7 @@ "html": "1.0.0", "html-loader": "^0.5.5", "imports-loader": "^0.8.0", - "jquery": "^3.4.1", + "jquery": "^3.5.0", "keymirror": "0.1.1", "moment": "^2.24.0", "node-sass": "^4.13.1", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index a2248f1ae655e..ae883a5032fe7 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -9,7 +9,7 @@ "kbn:watch": "node scripts/build --watch" }, "dependencies": { - "@elastic/charts": "19.1.2", + "@elastic/charts": "19.2.0", "@elastic/eui": "22.3.0", "@kbn/i18n": "1.0.0", "abortcontroller-polyfill": "^1.4.0", @@ -18,7 +18,7 @@ "core-js": "^3.6.4", "custom-event-polyfill": "^0.3.0", "elasticsearch-browser": "^16.7.0", - "jquery": "^3.4.1", + "jquery": "^3.5.0", "moment": "^2.24.0", "moment-timezone": "^0.5.27", "monaco-editor": "~0.17.0", diff --git a/scripts/kibana.js b/scripts/kibana.js index f5a63e6c07dd6..4da739469ffb1 100644 --- a/scripts/kibana.js +++ b/scripts/kibana.js @@ -17,6 +17,6 @@ * under the License. */ -require('../src/apm')(process.env.ELASTIC_APM_PROXY_SERVICE_NAME || 'kibana-proxy'); require('../src/setup_node_env'); +require('../src/apm')(process.env.ELASTIC_APM_PROXY_SERVICE_NAME || 'kibana-proxy'); require('../src/cli/cli'); diff --git a/src/cli/index.js b/src/cli/index.js index 45f88eaf82a5b..6dbdd800268a9 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -17,6 +17,6 @@ * under the License. */ -require('../apm')(); require('../setup_node_env'); +require('../apm')(); require('./cli'); diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 29d0fe16ee126..471939121143a 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -79,7 +79,7 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { set('optimize.watch', true); if (!has('elasticsearch.username')) { - set('elasticsearch.username', 'kibana'); + set('elasticsearch.username', 'kibana_system'); } if (!has('elasticsearch.password')) { diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 0dd77072e9eaf..8442f1ecc6411 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -26,6 +26,7 @@ import { InjectedMetadataSetup } from '../injected_metadata'; import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; import { ContextSetup, IContextContainer } from '../context'; +import { PluginOpaqueId } from '../plugins'; import { AppRouter } from './ui'; import { Capabilities, CapabilitiesService } from './capabilities'; import { @@ -34,7 +35,6 @@ import { AppLeaveHandler, AppMount, AppMountDeprecated, - AppMounter, AppNavLinkStatus, AppStatus, AppUpdatableFields, @@ -145,6 +145,25 @@ export class ApplicationService { this.subscriptions.push(subscription); }; + const wrapMount = (plugin: PluginOpaqueId, app: App): AppMount => { + let handler: AppMount; + if (isAppMountDeprecated(app.mount)) { + handler = this.mountContext!.createHandler(plugin, app.mount); + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.warn( + `App [${app.id}] is using deprecated mount context. Use core.getStartServices() instead.` + ); + } + } else { + handler = app.mount; + } + return async params => { + this.currentAppId$.next(app.id); + return handler(params); + }; + }; + return { registerMountContext: this.mountContext!.registerContext, register: (plugin, app: App) => { @@ -162,24 +181,6 @@ export class ApplicationService { throw new Error('Cannot register an application route that includes HTTP base path'); } - let handler: AppMount; - - if (isAppMountDeprecated(app.mount)) { - handler = this.mountContext!.createHandler(plugin, app.mount); - // eslint-disable-next-line no-console - console.warn( - `App [${app.id}] is using deprecated mount context. Use core.getStartServices() instead.` - ); - } else { - handler = app.mount; - } - - const mount: AppMounter = async params => { - const unmount = await handler(params); - this.currentAppId$.next(app.id); - return unmount; - }; - const { updater$, ...appProps } = app; this.apps.set(app.id, { ...appProps, @@ -193,7 +194,7 @@ export class ApplicationService { this.mounters.set(app.id, { appRoute: app.appRoute!, appBasePath: basePath.prepend(app.appRoute!), - mount, + mount: wrapMount(plugin, app), unmountBeforeMounting: false, }); }, diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index edf3583f384b8..60c36d3e330e0 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -17,6 +17,7 @@ * under the License. */ +import { take } from 'rxjs/operators'; import { createRenderer } from './utils'; import { createMemoryHistory, MemoryHistory } from 'history'; import { ApplicationService } from '../application_service'; @@ -56,6 +57,69 @@ describe('ApplicationService', () => { service = new ApplicationService(); }); + describe('navigating to apps', () => { + describe('using history.push', () => { + it('emits currentAppId$ before mounting the app', async () => { + const { register } = service.setup(setupDeps); + + let resolveMount: () => void; + const promise = new Promise(resolve => { + resolveMount = resolve; + }); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({}: AppMountParameters) => { + await promise; + return () => undefined; + }, + }); + + const { currentAppId$, getComponent } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await navigate('/app/app1'); + + expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); + + resolveMount!(); + + expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); + }); + }); + + describe('using navigateToApp', () => { + it('emits currentAppId$ before mounting the app', async () => { + const { register } = service.setup(setupDeps); + + let resolveMount: () => void; + const promise = new Promise(resolve => { + resolveMount = resolve; + }); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({}: AppMountParameters) => { + await promise; + return () => undefined; + }, + }); + + const { navigateToApp, currentAppId$ } = await service.start(startDeps); + + await navigateToApp('app1'); + + expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); + + resolveMount!(); + + expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); + }); + }); + }); + describe('leaving an application that registered an app leave handler', () => { it('navigates to the new app if action is default', async () => { startDeps.overlays.openConfirm.mockResolvedValue(true); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index bf531aaa00fac..a765ed47ea712 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -29,7 +29,6 @@ import { notificationServiceMock } from '../notifications/notifications_service. import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { ChromeService } from './chrome_service'; import { App } from '../application'; -import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; class FakeApp implements App { public title = `${this.id} App`; @@ -52,7 +51,6 @@ function defaultStartDeps(availableApps?: App[]) { http: httpServiceMock.createStartContract(), injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), - uiSettings: uiSettingsServiceMock.createStartContract(), }; if (availableApps) { @@ -163,7 +161,7 @@ describe('start', () => { }); describe('visibility', () => { - it('updates/emits the visibility', async () => { + it('emits false when no application is mounted', async () => { const { chrome, service } = await start(); const promise = chrome .getIsVisible$() @@ -177,33 +175,37 @@ describe('start', () => { await expect(promise).resolves.toMatchInlineSnapshot(` Array [ - true, - true, false, - true, + false, + false, + false, ] `); }); - it('always emits false if embed query string is preset when set up', async () => { + it('emits false until manually overridden when in embed mode', async () => { window.history.pushState(undefined, '', '#/home?a=b&embed=true'); + const startDeps = defaultStartDeps([new FakeApp('alpha')]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); - const { chrome, service } = await start(); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); + await navigateToApp('alpha'); + chrome.setIsVisible(true); chrome.setIsVisible(false); - chrome.setIsVisible(true); + service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` Array [ false, false, - false, + true, false, ] `); @@ -228,7 +230,7 @@ describe('start', () => { await expect(promise).resolves.toMatchInlineSnapshot(` Array [ - true, + false, true, false, true, @@ -245,13 +247,13 @@ describe('start', () => { .pipe(toArray()) .toPromise(); - navigateToApp('alpha'); + await navigateToApp('alpha'); chrome.setIsVisible(true); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` Array [ - true, + false, false, false, ] diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 7c9b644b8b984..3d9eeff09ecce 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -38,7 +38,6 @@ import { LoadingIndicator, Header } from './ui'; import { DocLinksStart } from '../doc_links'; import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; import { KIBANA_ASK_ELASTIC_LINK } from './constants'; -import { IUiSettingsClient } from '../ui_settings'; export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; const IS_LOCKED_KEY = 'core.chrome.isLocked'; @@ -85,14 +84,12 @@ interface StartDeps { http: HttpStart; injectedMetadata: InjectedMetadataStart; notifications: NotificationsStart; - uiSettings: IUiSettingsClient; } /** @internal */ export class ChromeService { private isVisible$!: Observable; - private appHidden$!: Observable; - private toggleHidden$!: BehaviorSubject; + private isForceHidden$!: BehaviorSubject; private readonly stop$ = new ReplaySubject(1); private readonly navControls = new NavControlsService(); private readonly navLinks = new NavLinksService(); @@ -111,13 +108,12 @@ export class ChromeService { private initVisibility(application: StartDeps['application']) { // Start off the chrome service hidden if "embed" is in the hash query string. const isEmbedded = 'embed' in parse(location.hash.slice(1), true).query; + this.isForceHidden$ = new BehaviorSubject(isEmbedded); - this.toggleHidden$ = new BehaviorSubject(isEmbedded); - this.appHidden$ = merge( - // Default the app being hidden to the same value initial value as the chrome visibility - // in case the application service has not emitted an app ID yet, since we want to trigger - // combineLatest below regardless of having an application value yet. - of(isEmbedded), + const appHidden$ = merge( + // For the isVisible$ logic, having no mounted app is equivalent to having a hidden app + // in the sense that the chrome UI should not be displayed until a non-chromeless app is mounting or mounted + of(true), application.currentAppId$.pipe( flatMap(appId => application.applications$.pipe( @@ -128,8 +124,8 @@ export class ChromeService { ) ) ); - this.isVisible$ = combineLatest([this.appHidden$, this.toggleHidden$]).pipe( - map(([appHidden, toggleHidden]) => !(appHidden || toggleHidden)), + this.isVisible$ = combineLatest([appHidden$, this.isForceHidden$]).pipe( + map(([appHidden, forceHidden]) => !appHidden && !forceHidden), takeUntil(this.stop$) ); } @@ -140,7 +136,6 @@ export class ChromeService { http, injectedMetadata, notifications, - uiSettings, }: StartDeps): Promise { this.initVisibility(application); @@ -221,7 +216,7 @@ export class ChromeService { getIsVisible$: () => this.isVisible$, - setIsVisible: (isVisible: boolean) => this.toggleHidden$.next(!isVisible), + setIsVisible: (isVisible: boolean) => this.isForceHidden$.next(!isVisible), getApplicationClasses$: () => applicationClasses$.pipe( diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index e58114b69dcc1..59f0142bb8890 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -240,7 +240,6 @@ export class CoreSystem { http, injectedMetadata, notifications, - uiSettings, }); application.registerMountContext(this.coreContext.coreId, 'core', () => ({ diff --git a/src/core/public/index.ts b/src/core/public/index.ts index b4f64125a03ef..c30996b83c946 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -77,7 +77,17 @@ import { } from './context'; export { CoreContext, CoreSystem } from './core_system'; -export { RecursiveReadonly, DEFAULT_APP_CATEGORIES } from '../utils'; +export { + RecursiveReadonly, + DEFAULT_APP_CATEGORIES, + getFlattenedObject, + URLMeaningfulParts, + modifyUrl, + isRelativeUrl, + Freezable, + deepFreeze, + assertNever, +} from '../utils'; export { AppCategory, UiSettingsParams, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index af06b207889c2..c9fad5952bc7a 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -16,6 +16,7 @@ import { Location } from 'history'; import { LocationDescriptorObject } from 'history'; import { MaybePromise } from '@kbn/utility-types'; import { Observable } from 'rxjs'; +import { ParsedQuery } from 'query-string'; import { PublicUiSettingsParams as PublicUiSettingsParams_2 } from 'src/core/server/types'; import React from 'react'; import * as Rx from 'rxjs'; @@ -174,6 +175,9 @@ export type AppUpdatableFields = Pick Partial | undefined; +// @public +export function assertNever(x: never): never; + // @public export interface Capabilities { [key: string]: Record>; @@ -434,6 +438,9 @@ export class CoreSystem { stop(): void; } +// @public +export function deepFreeze(object: T): RecursiveReadonly; + // @internal (undocumented) export const DEFAULT_APP_CATEGORIES: Readonly<{ analyze: { @@ -584,6 +591,16 @@ export interface FatalErrorsSetup { // @public export type FatalErrorsStart = FatalErrorsSetup; +// @public (undocumented) +export type Freezable = { + [k: string]: any; +} | any[]; + +// @public +export function getFlattenedObject(rootValue: Record): { + [key: string]: any; +}; + // @public export type HandlerContextType> = T extends HandlerFunction ? U : never; @@ -795,6 +812,9 @@ export interface ImageValidation { }; } +// @public +export function isRelativeUrl(candidatePath: string): boolean; + // @public export type IToasts = Pick; @@ -857,6 +877,9 @@ export interface LegacyNavLink { url: string; } +// @public +export function modifyUrl(url: string, urlModifier: (urlParts: URLMeaningfulParts) => Partial | void): string; + // @public export type MountPoint = (element: T) => UnmountCallback; @@ -1356,6 +1379,26 @@ export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'sel // @public export type UnmountCallback = () => void; +// @public +export interface URLMeaningfulParts { + // (undocumented) + auth?: string | null; + // (undocumented) + hash?: string | null; + // (undocumented) + hostname?: string | null; + // (undocumented) + pathname?: string | null; + // (undocumented) + port?: string | null; + // (undocumented) + protocol?: string | null; + // (undocumented) + query: ParsedQuery; + // (undocumented) + slashes?: boolean | null; +} + // @public export interface UserProvidedValues { // (undocumented) diff --git a/src/core/server/elasticsearch/__snapshots__/elasticsearch_config.test.ts.snap b/src/core/server/elasticsearch/__snapshots__/elasticsearch_config.test.ts.snap index e81336c8863f5..75627f311d9a5 100644 --- a/src/core/server/elasticsearch/__snapshots__/elasticsearch_config.test.ts.snap +++ b/src/core/server/elasticsearch/__snapshots__/elasticsearch_config.test.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`#username throws if equal to "elastic", only while running from source 1`] = `"[username]: value of \\"elastic\\" is forbidden. This is a superuser account that can obfuscate privilege-related issues. You should use the \\"kibana\\" user instead."`; +exports[`#username throws if equal to "elastic", only while running from source 1`] = `"[username]: value of \\"elastic\\" is forbidden. This is a superuser account that can obfuscate privilege-related issues. You should use the \\"kibana_system\\" user instead."`; diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index de3f57298f461..cb4501a51e849 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -315,12 +315,21 @@ describe('deprecations', () => { const { messages } = applyElasticsearchDeprecations({ username: 'elastic' }); expect(messages).toMatchInlineSnapshot(` Array [ - "Setting [${CONFIG_PATH}.username] to \\"elastic\\" is deprecated. You should use the \\"kibana\\" user instead.", + "Setting [${CONFIG_PATH}.username] to \\"elastic\\" is deprecated. You should use the \\"kibana_system\\" user instead.", ] `); }); - it('does not log a warning if elasticsearch.username is set to something besides "elastic"', () => { + it('logs a warning if elasticsearch.username is set to "kibana"', () => { + const { messages } = applyElasticsearchDeprecations({ username: 'kibana' }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.username] to \\"kibana\\" is deprecated. You should use the \\"kibana_system\\" user instead.", + ] + `); + }); + + it('does not log a warning if elasticsearch.username is set to something besides "elastic" or "kibana"', () => { const { messages } = applyElasticsearchDeprecations({ username: 'otheruser' }); expect(messages).toHaveLength(0); }); diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index d3012e361b3ed..c87c94bcd0b6a 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -55,7 +55,7 @@ export const configSchema = schema.object({ if (rawConfig === 'elastic') { return ( 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + - 'privilege-related issues. You should use the "kibana" user instead.' + 'privilege-related issues. You should use the "kibana_system" user instead.' ); } }, @@ -131,7 +131,11 @@ const deprecations: ConfigDeprecationProvider = () => [ } if (es.username === 'elastic') { log( - `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana" user instead.` + `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana_system" user instead.` + ); + } else if (es.username === 'kibana') { + log( + `Setting [${fromPath}.username] to "kibana" is deprecated. You should use the "kibana_system" user instead.` ); } if (es.ssl?.key !== undefined && es.ssl?.certificate === undefined) { 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 5726486a0930a..c7925f5b6d821 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -43,7 +43,7 @@ describe('http service', () => { describe('auth', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot(); + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); }, 30000); afterEach(async () => { @@ -192,7 +192,7 @@ describe('http service', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot(); + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); }, 30000); afterEach(async () => { @@ -326,7 +326,7 @@ describe('http service', () => { describe('#basePath()', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot(); + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); }, 30000); afterEach(async () => await root.shutdown()); @@ -355,7 +355,7 @@ describe('http service', () => { describe('elasticsearch', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot(); + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); }, 30000); afterEach(async () => { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 86192245bd2d1..cf999875b18f8 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -288,7 +288,17 @@ export { MetricsServiceSetup, } from './metrics'; -export { RecursiveReadonly } from '../utils'; +export { + RecursiveReadonly, + DEFAULT_APP_CATEGORIES, + getFlattenedObject, + URLMeaningfulParts, + modifyUrl, + isRelativeUrl, + Freezable, + deepFreeze, + assertNever, +} from '../utils'; export { SavedObject, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index e8b77a8570291..54b7a2ada69ad 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -103,6 +103,7 @@ import { NodesInfoParams } from 'elasticsearch'; import { NodesStatsParams } from 'elasticsearch'; import { ObjectType } from '@kbn/config-schema'; import { Observable } from 'rxjs'; +import { ParsedQuery } from 'query-string'; import { PeerCertificate } from 'tls'; import { PingParams } from 'elasticsearch'; import { PutScriptParams } from 'elasticsearch'; @@ -388,6 +389,9 @@ export interface APICaller { (endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; } +// @public +export function assertNever(x: never): never; + // @public (undocumented) export interface AssistanceAPIResponse { // (undocumented) @@ -691,6 +695,31 @@ export interface CustomHttpResponseOptions(object: T): RecursiveReadonly; + +// @internal (undocumented) +export const DEFAULT_APP_CATEGORIES: Readonly<{ + analyze: { + label: string; + order: number; + }; + observability: { + label: string; + euiIconType: string; + order: number; + }; + security: { + label: string; + order: number; + euiIconType: string; + }; + management: { + label: string; + euiIconType: string; + }; +}>; + // @public (undocumented) export interface DeprecationAPIClientParams extends GenericParams { // (undocumented) @@ -838,6 +867,11 @@ export interface FakeRequest { headers: Headers; } +// @public (undocumented) +export type Freezable = { + [k: string]: any; +} | any[]; + // @public export type GetAuthHeaders = (request: KibanaRequest | LegacyRequest) => AuthHeaders | undefined; @@ -847,6 +881,11 @@ export type GetAuthState = (request: KibanaRequest | LegacyRequest) state: T; }; +// @public +export function getFlattenedObject(rootValue: Record): { + [key: string]: any; +}; + // @public export type HandlerContextType> = T extends HandlerFunction ? U : never; @@ -1034,6 +1073,9 @@ export type ISavedObjectTypeRegistry = Omit; +// @public +export function isRelativeUrl(candidatePath: string): boolean; + // @public export interface IUiSettingsClient { get: (key: string) => Promise; @@ -1289,6 +1331,9 @@ export type MIGRATION_ASSISTANCE_INDEX_ACTION = 'upgrade' | 'reindex'; // @public (undocumented) export type MIGRATION_DEPRECATION_LEVEL = 'none' | 'info' | 'warning' | 'critical'; +// @public +export function modifyUrl(url: string, urlModifier: (urlParts: URLMeaningfulParts) => Partial | void): string; + // @public export type MutatingOperationRefreshSetting = boolean | 'wait_for'; @@ -2447,6 +2492,26 @@ export interface UiSettingsServiceStart { // @public export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +// @public +export interface URLMeaningfulParts { + // (undocumented) + auth?: string | null; + // (undocumented) + hash?: string | null; + // (undocumented) + hostname?: string | null; + // (undocumented) + pathname?: string | null; + // (undocumented) + port?: string | null; + // (undocumented) + protocol?: string | null; + // (undocumented) + query: ParsedQuery; + // (undocumented) + slashes?: boolean | null; +} + // @public export interface UserProvidedValues { // (undocumented) diff --git a/src/core/utils/assert_never.ts b/src/core/utils/assert_never.ts index 8e47f07a02a87..c713b373493c5 100644 --- a/src/core/utils/assert_never.ts +++ b/src/core/utils/assert_never.ts @@ -17,8 +17,12 @@ * under the License. */ -// Can be used in switch statements to ensure we perform exhaustive checks, see -// https://www.typescriptlang.org/docs/handbook/advanced-types.html#exhaustiveness-checking +/** + * Can be used in switch statements to ensure we perform exhaustive checks, see + * https://www.typescriptlang.org/docs/handbook/advanced-types.html#exhaustiveness-checking + * + * @public + */ export function assertNever(x: never): never { throw new Error(`Unexpected object: ${x}`); } diff --git a/src/core/utils/deep_freeze.ts b/src/core/utils/deep_freeze.ts index 8c3f8f2258b61..b0f283c60d0fc 100644 --- a/src/core/utils/deep_freeze.ts +++ b/src/core/utils/deep_freeze.ts @@ -17,8 +17,6 @@ * under the License. */ -type Freezable = { [k: string]: any } | any[]; - // if we define this inside RecursiveReadonly TypeScript complains // eslint-disable-next-line @typescript-eslint/no-empty-interface interface RecursiveReadonlyArray extends Array> {} @@ -32,6 +30,15 @@ export type RecursiveReadonly = T extends (...args: any[]) => any ? Readonly<{ [K in keyof T]: RecursiveReadonly }> : T; +/** @public */ +export type Freezable = { [k: string]: any } | any[]; + +/** + * Apply Object.freeze to a value recursively and convert the return type to + * Readonly variant recursively + * + * @public + */ export function deepFreeze(object: T) { // for any properties that reference an object, makes sure that object is // recursively frozen as well diff --git a/src/core/utils/get_flattened_object.ts b/src/core/utils/get_flattened_object.ts index ce03793284236..25ca0c7c83e26 100644 --- a/src/core/utils/get_flattened_object.ts +++ b/src/core/utils/get_flattened_object.ts @@ -30,8 +30,7 @@ function shouldReadKeys(value: unknown): value is Record { * getFlattenedObject({ a: { b: 1, c: [2,3] } }) * // => { 'a.b': 1, 'a.c': [2,3] } * - * @param {Object} rootValue - * @returns {Object} + * @public */ export function getFlattenedObject(rootValue: Record) { if (!shouldReadKeys(rootValue)) { diff --git a/src/core/utils/url.ts b/src/core/utils/url.ts index c2bf80ce3f86f..910fc8eaa4381 100644 --- a/src/core/utils/url.ts +++ b/src/core/utils/url.ts @@ -23,6 +23,8 @@ import { format as formatUrl, parse as parseUrl, UrlObject } from 'url'; * We define our own typings because the current version of @types/node * declares properties to be optional "hostname?: string". * Although, parse call returns "hostname: null | string". + * + * @public */ export interface URLMeaningfulParts { auth?: string | null; @@ -63,6 +65,7 @@ export interface URLMeaningfulParts { * @param url The string url to parse. * @param urlModifier A function that will modify the parsed url, or return a new one. * @returns The modified and reformatted url + * @public */ export function modifyUrl( url: string, @@ -100,6 +103,12 @@ export function modifyUrl( } as UrlObject); } +/** + * Determine if a url is relative. Any url including a protocol, hostname, or + * port is not considered relative. This means that absolute *paths* are considered + * to be relative *urls* + * @public + */ export function isRelativeUrl(candidatePath: string) { // validate that `candidatePath` is not attempting a redirect to somewhere // outside of this Kibana install diff --git a/src/dev/build/build_distributables.js b/src/dev/build/build_distributables.js index 6c2efeebc60c3..910313ac87059 100644 --- a/src/dev/build/build_distributables.js +++ b/src/dev/build/build_distributables.js @@ -40,6 +40,7 @@ import { CreatePackageJsonTask, CreateReadmeTask, CreateRpmPackageTask, + CreateStaticFsWithNodeModulesTask, DownloadNodeBuildsTask, ExtractNodeBuildsTask, InstallDependenciesTask, @@ -126,6 +127,7 @@ export async function buildDistributables(options) { await run(CleanTypescriptTask); await run(CleanExtraFilesFromModulesTask); await run(CleanEmptyFoldersTask); + await run(CreateStaticFsWithNodeModulesTask); /** * copy generic build outputs into platform-specific build diff --git a/src/dev/build/tasks/create_static_fs_with_node_modules_task.js b/src/dev/build/tasks/create_static_fs_with_node_modules_task.js new file mode 100644 index 0000000000000..0ab296fc5c163 --- /dev/null +++ b/src/dev/build/tasks/create_static_fs_with_node_modules_task.js @@ -0,0 +1,64 @@ +/* + * 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 del from 'del'; +import globby from 'globby'; +import { resolve } from 'path'; +import { generateStaticFsVolume } from '@elastic/static-fs'; + +async function deletePathsList(list) { + for (const path of list) { + await del(path); + } +} + +async function getTopLevelNodeModulesFolders(rootDir) { + const nodeModulesFoldersForCwd = await globby(['**/node_modules', '!**/node_modules/**/*'], { + cwd: rootDir, + onlyDirectories: true, + }); + + return nodeModulesFoldersForCwd.map(folder => resolve(rootDir, folder)); +} + +export const CreateStaticFsWithNodeModulesTask = { + description: + 'Creating static filesystem with node_modules, patching entryPoints and deleting node_modules folder', + + async run(config, log, build) { + const rootDir = build.resolvePath('.'); + + // Get all the top node_modules folders + const nodeModulesFolders = await getTopLevelNodeModulesFolders(rootDir); + + // Define root entry points + const rootEntryPoints = [build.resolvePath('src/setup_node_env/index.js')]; + + // Creates the static filesystem with + // every node_module we have + const staticFsAddedPaths = await generateStaticFsVolume( + rootDir, + nodeModulesFolders, + rootEntryPoints + ); + + // Delete node_modules folder + await deletePathsList(staticFsAddedPaths); + }, +}; diff --git a/src/dev/build/tasks/index.js b/src/dev/build/tasks/index.js index 8105fa8a7d5d4..0232ac4b1b5f3 100644 --- a/src/dev/build/tasks/index.js +++ b/src/dev/build/tasks/index.js @@ -25,6 +25,7 @@ export * from './create_archives_task'; export * from './create_empty_dirs_and_files_task'; export * from './create_package_json_task'; export * from './create_readme_task'; +export * from './create_static_fs_with_node_modules_task'; export * from './install_dependencies_task'; export * from './license_file_task'; export * from './nodejs'; diff --git a/src/legacy/core_plugins/interpreter/README.md b/src/legacy/core_plugins/interpreter/README.md deleted file mode 100644 index 6d90ce2d5e2eb..0000000000000 --- a/src/legacy/core_plugins/interpreter/README.md +++ /dev/null @@ -1,2 +0,0 @@ -Interpreter legacy plugin has been migrated to the New Platform. Use -`expressions` New Platform plugin instead. diff --git a/src/legacy/core_plugins/interpreter/index.ts b/src/legacy/core_plugins/interpreter/index.ts deleted file mode 100644 index 9427a2f8a2d0f..0000000000000 --- a/src/legacy/core_plugins/interpreter/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { resolve } from 'path'; -import { Legacy } from '../../../../kibana'; -import { init } from './init'; - -// eslint-disable-next-line -export default function InterpreterPlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'interpreter', - require: ['kibana', 'elasticsearch'], - publicDir: resolve(__dirname, 'public'), - uiExports: { - injectDefaultVars: server => ({ - serverBasePath: server.config().get('server.basePath'), - }), - }, - config: (Joi: any) => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - init, - }; - - return new kibana.Plugin(config); -} diff --git a/src/legacy/core_plugins/interpreter/init.ts b/src/legacy/core_plugins/interpreter/init.ts deleted file mode 100644 index 46da1539afadb..0000000000000 --- a/src/legacy/core_plugins/interpreter/init.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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. - */ - -/* eslint-disable max-classes-per-file */ - -// @ts-ignore -import { register, registryFactory, Registry, Fn } from '@kbn/interpreter/common'; - -import { Legacy } from '../../../../kibana'; - -export async function init(server: Legacy.Server /* options */) { - server.injectUiAppVars('canvas', () => { - const config = server.config(); - const basePath = config.get('server.basePath'); - const reportingBrowserType = (() => { - const configKey = 'xpack.reporting.capture.browser.type'; - if (!config.has(configKey)) { - return null; - } - return config.get(configKey); - })(); - - return { - kbnIndex: config.get('kibana.index'), - serverFunctions: (server.newPlatform.setup.plugins.expressions as any).__LEGACY - .registries() - .serverFunctions.toArray(), - basePath, - reportingBrowserType, - }; - }); - - // Expose server.plugins.interpreter.register(specs) and - // server.plugins.interpreter.registries() (a getter). - server.expose((server.newPlatform.setup.plugins.expressions as any).__LEGACY); -} diff --git a/src/legacy/core_plugins/interpreter/package.json b/src/legacy/core_plugins/interpreter/package.json deleted file mode 100644 index 3265dadd7fbfc..0000000000000 --- a/src/legacy/core_plugins/interpreter/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "interpreter", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/interpreter/public/canvas/load_legacy_server_function_wrappers.ts b/src/legacy/core_plugins/interpreter/public/canvas/load_legacy_server_function_wrappers.ts deleted file mode 100644 index fed157846a1a1..0000000000000 --- a/src/legacy/core_plugins/interpreter/public/canvas/load_legacy_server_function_wrappers.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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. - */ - -/** - * This file needs to be deleted by 8.0 release. It is here to load available - * server side functions and create a wrappers around them on client side, to - * execute them from client side. This functionality is used only by Canvas - * and all server side functions are in Canvas plugin. - * - * In 8.0 there will be no server-side functions, plugins will register only - * client side functions and if they need those to execute something on the - * server side, it should be respective function's internal implementation detail. - */ - -import { npSetup } from 'ui/new_platform'; - -export const { loadLegacyServerFunctionWrappers } = npSetup.plugins.expressions.__LEGACY; diff --git a/src/legacy/core_plugins/interpreter/public/interpreter.ts b/src/legacy/core_plugins/interpreter/public/interpreter.ts deleted file mode 100644 index 319a2779010c3..0000000000000 --- a/src/legacy/core_plugins/interpreter/public/interpreter.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 'uiExports/interpreter'; -// @ts-ignore -import { register, registryFactory } from '@kbn/interpreter/common'; -import { npSetup } from 'ui/new_platform'; -import { registries } from './registries'; -import { Executor, ExpressionExecutor } from '../../../../plugins/expressions/public'; - -// Expose kbnInterpreter.register(specs) and kbnInterpreter.registries() globally so that plugins -// can register without a transpile step. -// TODO: This will be left behind in then legacy platform? -(global as any).kbnInterpreter = Object.assign( - (global as any).kbnInterpreter || {}, - registryFactory(registries) -); - -// TODO: This function will be left behind in the legacy platform. -let executorPromise: Promise | undefined; -export const getInterpreter = async () => { - if (!executorPromise) { - const executor = npSetup.plugins.expressions.__LEGACY.getExecutor(); - executorPromise = Promise.resolve(executor); - } - return await executorPromise; -}; - -// TODO: This function will be left behind in the legacy platform. -export const interpretAst: Executor['run'] = async (ast, context, handlers) => { - const { interpreter } = await getInterpreter(); - return await interpreter.interpretAst(ast, context, handlers); -}; diff --git a/src/legacy/core_plugins/interpreter/public/registries.karma_mock.ts b/src/legacy/core_plugins/interpreter/public/registries.karma_mock.ts deleted file mode 100644 index 0f37f33cc1b13..0000000000000 --- a/src/legacy/core_plugins/interpreter/public/registries.karma_mock.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 sinon from 'sinon'; - -export const functionsRegistry = {}; -export const renderersRegistry = {}; -export const typesRegistry = {}; -export const registries = { - browserFunctions: functionsRegistry, - renderers: renderersRegistry, - types: typesRegistry, - loadLegacyServerFunctionWrappers: () => Promise.resolve(), -}; - -const resetRegistry = (registry: any) => { - registry.wrapper = sinon.stub(); - registry.register = sinon.stub(); - registry.toJS = sinon.stub(); - registry.toArray = sinon.stub(); - registry.get = sinon.stub(); - registry.getProp = sinon.stub(); - registry.reset = sinon.stub(); -}; -const resetAll = () => Object.values(registries).forEach(resetRegistry); - -resetAll(); -afterEach(resetAll); diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 51577456135d1..e786890567740 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -24,11 +24,11 @@ import { promisify } from 'util'; import { importApi } from './server/routes/api/import'; import { exportApi } from './server/routes/api/export'; import mappings from './mappings.json'; -import { getUiSettingDefaults } from './ui_setting_defaults'; +import { getUiSettingDefaults } from './server/ui_setting_defaults'; import { registerCspCollector } from './server/lib/csp_usage_collector'; import { injectVars } from './inject_vars'; import { i18n } from '@kbn/i18n'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { kbnBaseUrl } from '../../../plugins/kibana_legacy/server'; const mkdirAsync = promisify(Fs.mkdir); diff --git a/src/legacy/core_plugins/kibana/ui_setting_defaults.js b/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js similarity index 99% rename from src/legacy/core_plugins/kibana/ui_setting_defaults.js rename to src/legacy/core_plugins/kibana/server/ui_setting_defaults.js index 85b1956f45333..64b7dfe22fd57 100644 --- a/src/legacy/core_plugins/kibana/ui_setting_defaults.js +++ b/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * under the License. */ + import moment from 'moment-timezone'; import numeralLanguages from '@elastic/numeral/languages'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { DEFAULT_QUERY_LANGUAGE } from '../../../plugins/data/common'; -import { isRelativeUrl } from '../../../core/utils'; +import { isRelativeUrl } from '../../../../core/server'; +import { DEFAULT_QUERY_LANGUAGE } from '../../../../plugins/data/common'; export function getUiSettingDefaults() { const weekdays = moment.weekdays().slice(); diff --git a/src/legacy/core_plugins/timelion/index.ts b/src/legacy/core_plugins/timelion/index.ts index 41a15dc4e0186..4fdf27d7cf655 100644 --- a/src/legacy/core_plugins/timelion/index.ts +++ b/src/legacy/core_plugins/timelion/index.ts @@ -21,7 +21,7 @@ import { resolve } from 'path'; import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { LegacyPluginApi, LegacyPluginInitializer } from 'src/legacy/plugin_discovery/types'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/server'; const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', { defaultMessage: 'experimental', diff --git a/src/legacy/ui/public/field_editor/field_editor.test.tsx b/src/legacy/ui/public/field_editor/field_editor.test.tsx index 92264fbfbc94d..ced7aa27e5065 100644 --- a/src/legacy/ui/public/field_editor/field_editor.test.tsx +++ b/src/legacy/ui/public/field_editor/field_editor.test.tsx @@ -22,8 +22,8 @@ import React from 'react'; import { npStart } from 'ui/new_platform'; import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; import { - Field, IndexPattern, + IndexPatternField, IIndexPatternFieldList, FieldFormatInstanceType, } from 'src/plugins/data/public'; @@ -76,10 +76,10 @@ jest.mock('./components/field_format_editor', () => ({ FieldFormatEditor: 'field-format-editor', })); -const fields: Field[] = [ +const fields: IndexPatternField[] = [ { name: 'foobar', - } as Field, + } as IndexPatternField, ]; // @ts-ignore @@ -133,7 +133,7 @@ describe('FieldEditor', () => { const component = shallowWithI18nProvider( ); @@ -149,18 +149,18 @@ describe('FieldEditor', () => { name: 'test', script: 'doc.test.value', }; - indexPattern.fields.push(testField as Field); + indexPattern.fields.push(testField as IndexPatternField); indexPattern.fields.getByName = name => { const flds = { [testField.name]: testField, }; - return flds[name] as Field; + return flds[name] as IndexPatternField; }; const component = shallowWithI18nProvider( ); @@ -177,18 +177,18 @@ describe('FieldEditor', () => { script: 'doc.test.value', lang: 'testlang', }; - indexPattern.fields.push((testField as unknown) as Field); + indexPattern.fields.push((testField as unknown) as IndexPatternField); indexPattern.fields.getByName = name => { const flds = { [testField.name]: testField, }; - return flds[name] as Field; + return flds[name] as IndexPatternField; }; const component = shallowWithI18nProvider( ); @@ -203,7 +203,7 @@ describe('FieldEditor', () => { const component = shallowWithI18nProvider( ); @@ -226,7 +226,7 @@ describe('FieldEditor', () => { const component = shallowWithI18nProvider( ); diff --git a/src/legacy/ui/public/field_editor/field_editor.tsx b/src/legacy/ui/public/field_editor/field_editor.tsx index aa62a53f2c32a..7de70f5d956e8 100644 --- a/src/legacy/ui/public/field_editor/field_editor.tsx +++ b/src/legacy/ui/public/field_editor/field_editor.tsx @@ -55,13 +55,13 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { + IndexPatternField, + FieldFormatInstanceType, IndexPattern, IFieldType, KBN_FIELD_TYPES, ES_FIELD_TYPES, } from '../../../../plugins/data/public'; -import { FieldFormatInstanceType } from '../../../../plugins/data/common'; -import { Field } from '../../../../plugins/data/public'; import { ScriptingDisabledCallOut, ScriptingWarningCallOut, @@ -114,7 +114,7 @@ interface InitialFieldTypeFormat extends FieldTypeFormat { defaultFieldFormat: FieldFormatInstanceType; } -interface FieldClone extends Field { +interface FieldClone extends IndexPatternField { format: any; } @@ -139,7 +139,7 @@ export interface FieldEditorState { export interface FieldEdiorProps { indexPattern: IndexPattern; - field: Field; + field: IndexPatternField; helpers: { getConfig: (key: string) => any; getHttpStart: () => HttpStart; diff --git a/src/legacy/ui/public/url/kibana_parsed_url.ts b/src/legacy/ui/public/url/kibana_parsed_url.ts index 93d2e17d6038f..22288160acc6d 100644 --- a/src/legacy/ui/public/url/kibana_parsed_url.ts +++ b/src/legacy/ui/public/url/kibana_parsed_url.ts @@ -19,7 +19,7 @@ import { parse } from 'url'; -import { modifyUrl } from '../../../../core/utils'; +import { modifyUrl } from '../../../../core/public'; import { prependPath } from './prepend_path'; interface Options { diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index cf62de82bcf4b..47f7aa1635205 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -24,7 +24,7 @@ import { parse } from 'query-string'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import { useUIAceKeyboardMode } from '../../../../../../../es_ui_shared/public'; // @ts-ignore -import mappings from '../../../../../lib/mappings/mappings'; +import { retrieveAutoCompleteInfo, clearSubscriptions } from '../../../../../lib/mappings/mappings'; import { ConsoleMenu } from '../../../../components'; import { useEditorReadContext, useServicesContext } from '../../../../contexts'; import { @@ -172,14 +172,14 @@ function EditorUI({ initialTextValue }: EditorProps) { setInputEditor(editor); setTextArea(editorRef.current!.querySelector('textarea')); - mappings.retrieveAutoCompleteInfo(settingsService, settingsService.getAutocomplete()); + retrieveAutoCompleteInfo(settingsService, settingsService.getAutocomplete()); const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor); setupAutosave(); return () => { unsubscribeResizer(); - mappings.clearSubscriptions(); + clearSubscriptions(); window.removeEventListener('hashchange', onHashChange); }; }, [saveCurrentTextObject, initialTextValue, history, setInputEditor, settingsService]); diff --git a/src/plugins/console/public/application/containers/settings.tsx b/src/plugins/console/public/application/containers/settings.tsx index e34cfcac8096b..81938a83435de 100644 --- a/src/plugins/console/public/application/containers/settings.tsx +++ b/src/plugins/console/public/application/containers/settings.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { AutocompleteOptions, DevToolsSettingsModal } from '../components'; // @ts-ignore -import mappings from '../../lib/mappings/mappings'; +import { retrieveAutoCompleteInfo } from '../../lib/mappings/mappings'; import { useServicesContext, useEditorActionContext } from '../contexts'; import { DevToolsSettings, Settings as SettingsService } from '../../services'; @@ -33,7 +33,7 @@ const getAutocompleteDiff = (newSettings: DevToolsSettings, prevSettings: DevToo }; const refreshAutocompleteSettings = (settings: SettingsService, selectedSettings: any) => { - mappings.retrieveAutoCompleteInfo(settings, selectedSettings); + retrieveAutoCompleteInfo(settings, selectedSettings); }; const fetchAutocompleteSettingsIfNeeded = ( @@ -61,10 +61,10 @@ const fetchAutocompleteSettingsIfNeeded = ( }, {} ); - mappings.retrieveAutoCompleteInfo(settings, changedSettings); + retrieveAutoCompleteInfo(settings, changedSettings); } else if (isPollingChanged && newSettings.polling) { // If the user has turned polling on, then we'll fetch all selected autocomplete settings. - mappings.retrieveAutoCompleteInfo(settings, settings.getAutocomplete()); + retrieveAutoCompleteInfo(settings, settings.getAutocomplete()); } } }; diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts index dde793d9b9691..f0ce61f1d3401 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts @@ -24,7 +24,7 @@ import { sendRequestToES } from './send_request_to_es'; import { track } from './track'; // @ts-ignore -import mappings from '../../../lib/mappings/mappings'; +import { retrieveAutoCompleteInfo } from '../../../lib/mappings/mappings'; export const useSendCurrentRequestToES = () => { const { @@ -73,7 +73,7 @@ export const useSendCurrentRequestToES = () => { // or templates may have changed, so we'll need to update this data. Assume that if // the user disables polling they're trying to optimize performance or otherwise // preserve resources, so they won't want this request sent either. - mappings.retrieveAutoCompleteInfo(settings, settings.getAutocomplete()); + retrieveAutoCompleteInfo(settings, settings.getAutocomplete()); } dispatch({ diff --git a/src/plugins/console/public/application/models/legacy_core_editor/__tests__/output_tokenization.test.js b/src/plugins/console/public/application/models/legacy_core_editor/__tests__/output_tokenization.test.js index 5c86b0a1d2092..1db9ca7bc0a86 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/__tests__/output_tokenization.test.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/__tests__/output_tokenization.test.js @@ -17,7 +17,7 @@ * under the License. */ import '../legacy_core_editor.test.mocks'; -const $ = require('jquery'); +import $ from 'jquery'; import RowParser from '../../../../lib/row_parser'; import ace from 'brace'; import { createReadOnlyAceEditor } from '../create_readonly'; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/input.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/input.js index d763db7ae5d79..77b4ba8cea6ff 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/input.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/input.js @@ -19,20 +19,21 @@ import ace from 'brace'; import { workerModule } from './worker'; +import { ScriptMode } from './script'; const oop = ace.acequire('ace/lib/oop'); const TextMode = ace.acequire('ace/mode/text').Mode; -const ScriptMode = require('./script').ScriptMode; + const MatchingBraceOutdent = ace.acequire('ace/mode/matching_brace_outdent').MatchingBraceOutdent; const CstyleBehaviour = ace.acequire('ace/mode/behaviour/cstyle').CstyleBehaviour; const CStyleFoldMode = ace.acequire('ace/mode/folding/cstyle').FoldMode; const WorkerClient = ace.acequire('ace/worker/worker_client').WorkerClient; const AceTokenizer = ace.acequire('ace/tokenizer').Tokenizer; -const HighlightRules = require('./input_highlight_rules').InputHighlightRules; +import { InputHighlightRules } from './input_highlight_rules'; export function Mode() { - this.$tokenizer = new AceTokenizer(new HighlightRules().getRules()); + this.$tokenizer = new AceTokenizer(new InputHighlightRules().getRules()); this.$outdent = new MatchingBraceOutdent(); this.$behaviour = new CstyleBehaviour(); this.foldingRules = new CStyleFoldMode(); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js index 29f192f4ea858..1558cf0cb5554 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js @@ -17,7 +17,7 @@ * under the License. */ -const ace = require('brace'); +import ace from 'brace'; import { addXJsonToRules } from '../../../../../../es_ui_shared/public'; export function addEOL(tokens, reg, nextIfEOL, normalNext) { diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/output.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/output.js index 40e3128e396a3..5ad34532d1861 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/output.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/output.js @@ -18,11 +18,11 @@ */ import ace from 'brace'; -require('./output_highlight_rules'); + +import { OutputJsonHighlightRules } from './output_highlight_rules'; const oop = ace.acequire('ace/lib/oop'); const JSONMode = ace.acequire('ace/mode/json').Mode; -const HighlightRules = require('./output_highlight_rules').OutputJsonHighlightRules; const MatchingBraceOutdent = ace.acequire('ace/mode/matching_brace_outdent').MatchingBraceOutdent; const CstyleBehaviour = ace.acequire('ace/mode/behaviour/cstyle').CstyleBehaviour; const CStyleFoldMode = ace.acequire('ace/mode/folding/cstyle').FoldMode; @@ -30,7 +30,7 @@ ace.acequire('ace/worker/worker_client'); const AceTokenizer = ace.acequire('ace/tokenizer').Tokenizer; export function Mode() { - this.$tokenizer = new AceTokenizer(new HighlightRules().getRules()); + this.$tokenizer = new AceTokenizer(new OutputJsonHighlightRules().getRules()); this.$outdent = new MatchingBraceOutdent(); this.$behaviour = new CstyleBehaviour(); this.foldingRules = new CStyleFoldMode(); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js index c9d538ab6ceb1..448fd847aeacd 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js @@ -17,7 +17,7 @@ * under the License. */ -const ace = require('brace'); +import ace from 'brace'; import 'brace/mode/json'; import { addXJsonToRules } from '../../../../../../es_ui_shared/public'; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/theme_sense_dark.js b/src/plugins/console/public/application/models/legacy_core_editor/theme_sense_dark.js index f79a171c65082..8a0eb9a03480b 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/theme_sense_dark.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/theme_sense_dark.js @@ -18,7 +18,7 @@ */ /* eslint import/no-unresolved: 0 */ -const ace = require('brace'); +import ace from 'brace'; ace.define('ace/theme/sense-dark', ['require', 'exports', 'module'], function(require, exports) { exports.isDark = true; diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js index c5a0c2ebddf71..edd09885c1ad2 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js @@ -19,10 +19,10 @@ import '../sense_editor.test.mocks'; import { create } from '../create'; import _ from 'lodash'; -const $ = require('jquery'); +import $ from 'jquery'; -const kb = require('../../../../lib/kb/kb'); -const mappings = require('../../../../lib/mappings/mappings'); +import * as kb from '../../../../lib/kb/kb'; +import * as mappings from '../../../../lib/mappings/mappings'; describe('Integration', () => { let senseEditor; diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js b/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js index 34b4cad7fbb6b..219e6262ab346 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js @@ -23,7 +23,7 @@ import _ from 'lodash'; import { create } from '../create'; import { collapseLiteralStrings } from '../../../../../../es_ui_shared/public'; -const editorInput1 = require('./editor_input1.txt'); +import editorInput1 from './editor_input1.txt'; describe('Editor', () => { let input; diff --git a/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js index 0758a75695566..ebde8c39cffbc 100644 --- a/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js @@ -17,7 +17,7 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; import { URL_PATH_END_MARKER, UrlPatternMatcher, diff --git a/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js index 72fce53c4f1fe..286aefcd133a0 100644 --- a/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; import { UrlParams } from '../../autocomplete/url_params'; import { populateContext } from '../../autocomplete/engine'; diff --git a/src/plugins/console/public/lib/autocomplete/body_completer.js b/src/plugins/console/public/lib/autocomplete/body_completer.js index 1aa315c50b9bf..9bb1f14a6266a 100644 --- a/src/plugins/console/public/lib/autocomplete/body_completer.js +++ b/src/plugins/console/public/lib/autocomplete/body_completer.js @@ -17,7 +17,7 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; import { WalkingState, walkTokenPath, wrapComponentWithDefaults } from './engine'; import { ConstantComponent, diff --git a/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js index 0574ffbcfc3a9..80f65406cf5d3 100644 --- a/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js @@ -17,11 +17,11 @@ * under the License. */ import _ from 'lodash'; -import mappings from '../../mappings/mappings'; +import { getFields } from '../../mappings/mappings'; import { ListComponent } from './list_component'; function FieldGenerator(context) { - return _.map(mappings.getFields(context.indices, context.types), function(field) { + return _.map(getFields(context.indices, context.types), function(field) { return { name: field.name, meta: field.type }; }); } diff --git a/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js index 03d67c9e27ee8..ec6f24253e78d 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js @@ -17,14 +17,14 @@ * under the License. */ import _ from 'lodash'; -import mappings from '../../mappings/mappings'; +import { getIndices } from '../../mappings/mappings'; import { ListComponent } from './list_component'; function nonValidIndexType(token) { return !(token === '_all' || token[0] !== '_'); } export class IndexAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, mappings.getIndices, parent, multiValued); + super(name, getIndices, parent, multiValued); } validateTokens(tokens) { if (!this.multiValued && tokens.length > 1) { diff --git a/src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js index cc62a2f9eeea6..14141980d493d 100644 --- a/src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js @@ -16,12 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import mappings from '../../mappings/mappings'; +import { getTemplates } from '../../mappings/mappings'; import { ListComponent } from './list_component'; export class TemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, mappings.getTemplates, parent, true, true); + super(name, getTemplates, parent, true, true); } getContextKey() { return 'template'; diff --git a/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js index 507817c1ed8c5..03d85eccaf385 100644 --- a/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js @@ -18,9 +18,9 @@ */ import _ from 'lodash'; import { ListComponent } from './list_component'; -import mappings from '../../mappings/mappings'; +import { getTypes } from '../../mappings/mappings'; function TypeGenerator(context) { - return mappings.getTypes(context.indices); + return getTypes(context.indices); } function nonValidIndexType(token) { return !(token === '_all' || token[0] !== '_'); diff --git a/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js index 26b7bd5c48c99..14b77d4e70625 100644 --- a/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js @@ -17,14 +17,14 @@ * under the License. */ import _ from 'lodash'; -import mappings from '../../mappings/mappings'; +import { getIndices } from '../../mappings/mappings'; import { ListComponent } from './list_component'; function nonValidUsernameType(token) { return token[0] === '_'; } export class UsernameAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, mappings.getIndices, parent, multiValued); + super(name, getIndices, parent, multiValued); } validateTokens(tokens) { if (!this.multiValued && tokens.length > 1) { diff --git a/src/plugins/console/public/lib/autocomplete/engine.js b/src/plugins/console/public/lib/autocomplete/engine.js index 7b64d91c95374..ae943a74ffb3a 100644 --- a/src/plugins/console/public/lib/autocomplete/engine.js +++ b/src/plugins/console/public/lib/autocomplete/engine.js @@ -17,7 +17,7 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; export function wrapComponentWithDefaults(component, defaults) { const originalGetTerms = component.getTerms; diff --git a/src/plugins/console/public/lib/autocomplete/url_params.js b/src/plugins/console/public/lib/autocomplete/url_params.js index 3f05b9ce85aab..0519a2daade87 100644 --- a/src/plugins/console/public/lib/autocomplete/url_params.js +++ b/src/plugins/console/public/lib/autocomplete/url_params.js @@ -17,7 +17,7 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; import { ConstantComponent, ListComponent, SharedComponent } from './components'; export class ParamComponent extends ConstantComponent { diff --git a/src/plugins/console/public/lib/curl_parsing/__tests__/curl_parsing.test.js b/src/plugins/console/public/lib/curl_parsing/__tests__/curl_parsing.test.js index 49a54eaefa9ef..6a8caebfc6874 100644 --- a/src/plugins/console/public/lib/curl_parsing/__tests__/curl_parsing.test.js +++ b/src/plugins/console/public/lib/curl_parsing/__tests__/curl_parsing.test.js @@ -17,15 +17,15 @@ * under the License. */ -const _ = require('lodash'); -const curl = require('../curl'); +import _ from 'lodash'; +import { detectCURL, parseCURL } from '../curl'; import curlTests from './curl_parsing.txt'; describe('CURL', () => { const notCURLS = ['sldhfsljfhs', 's;kdjfsldkfj curl -XDELETE ""', '{ "hello": 1 }']; _.each(notCURLS, function(notCURL, i) { test('cURL Detection - broken strings ' + i, function() { - expect(curl.detectCURL(notCURL)).toEqual(false); + expect(detectCURL(notCURL)).toEqual(false); }); }); @@ -39,8 +39,8 @@ describe('CURL', () => { const response = fixture[2].trim(); test('cURL Detection - ' + name, function() { - expect(curl.detectCURL(curlText)).toBe(true); - const r = curl.parseCURL(curlText); + expect(detectCURL(curlText)).toBe(true); + const r = parseCURL(curlText); expect(r).toEqual(response); }); }); diff --git a/src/plugins/console/public/lib/kb/__tests__/kb.test.js b/src/plugins/console/public/lib/kb/__tests__/kb.test.js index c2c69314a172d..c80f5671449b3 100644 --- a/src/plugins/console/public/lib/kb/__tests__/kb.test.js +++ b/src/plugins/console/public/lib/kb/__tests__/kb.test.js @@ -21,8 +21,8 @@ import _ from 'lodash'; import { populateContext } from '../../autocomplete/engine'; import '../../../application/models/sense_editor/sense_editor.test.mocks'; -const kb = require('../../kb'); -const mappings = require('../../mappings/mappings'); +import * as kb from '../../kb'; +import * as mappings from '../../mappings/mappings'; describe('Knowledge base', () => { beforeEach(() => { diff --git a/src/plugins/console/public/lib/kb/api.js b/src/plugins/console/public/lib/kb/api.js index eeec87060b770..c418a7cb414ef 100644 --- a/src/plugins/console/public/lib/kb/api.js +++ b/src/plugins/console/public/lib/kb/api.js @@ -17,7 +17,7 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; import { UrlPatternMatcher } from '../autocomplete/components'; import { UrlParams } from '../autocomplete/url_params'; import { diff --git a/src/plugins/console/public/lib/mappings/__tests__/mapping.test.js b/src/plugins/console/public/lib/mappings/__tests__/mapping.test.js index 27b3ce26b5588..292b1b4fb1bf5 100644 --- a/src/plugins/console/public/lib/mappings/__tests__/mapping.test.js +++ b/src/plugins/console/public/lib/mappings/__tests__/mapping.test.js @@ -17,7 +17,7 @@ * under the License. */ import '../../../application/models/sense_editor/sense_editor.test.mocks'; -const mappings = require('../mappings'); +import * as mappings from '../mappings'; describe('Mappings', () => { beforeEach(() => { diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js index 330147118d42c..b5bcc2b105996 100644 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ b/src/plugins/console/public/lib/mappings/mappings.js @@ -17,9 +17,9 @@ * under the License. */ -const $ = require('jquery'); -const _ = require('lodash'); -const es = require('../es/es'); +import $ from 'jquery'; +import _ from 'lodash'; +import * as es from '../es/es'; // NOTE: If this value ever changes to be a few seconds or less, it might introduce flakiness // due to timing issues in our app.js tests. @@ -32,7 +32,7 @@ let templates = []; const mappingObj = {}; -function expandAliases(indicesOrAliases) { +export function expandAliases(indicesOrAliases) { // takes a list of indices or aliases or a string which may be either and returns a list of indices // returns a list for multiple values or a string for a single. @@ -60,11 +60,11 @@ function expandAliases(indicesOrAliases) { return ret.length > 1 ? ret : ret[0]; } -function getTemplates() { +export function getTemplates() { return [...templates]; } -function getFields(indices, types) { +export function getFields(indices, types) { // get fields for indices and types. Both can be a list, a string or null (meaning all). let ret = []; indices = expandAliases(indices); @@ -103,7 +103,7 @@ function getFields(indices, types) { }); } -function getTypes(indices) { +export function getTypes(indices) { let ret = []; indices = expandAliases(indices); if (typeof indices === 'string') { @@ -129,7 +129,7 @@ function getTypes(indices) { return _.uniq(ret); } -function getIndices(includeAliases) { +export function getIndices(includeAliases) { const ret = []; $.each(perIndexTypes, function(index) { ret.push(index); @@ -200,7 +200,7 @@ function loadTemplates(templatesObject = {}) { templates = Object.keys(templatesObject); } -function loadMappings(mappings) { +export function loadMappings(mappings) { perIndexTypes = {}; $.each(mappings, function(index, indexMapping) { @@ -224,7 +224,7 @@ function loadMappings(mappings) { }); } -function loadAliases(aliases) { +export function loadAliases(aliases) { perAliasIndexes = {}; $.each(aliases || {}, function(index, omdexAliases) { // verify we have an index defined. useful when mapping loading is disabled @@ -246,7 +246,7 @@ function loadAliases(aliases) { perAliasIndexes._all = getIndices(false); } -function clear() { +export function clear() { perIndexTypes = {}; perAliasIndexes = {}; templates = []; @@ -285,7 +285,7 @@ function retrieveSettings(settingsKey, settingsToRetrieve) { // unchanged alone (both selected and unselected). // 3. Poll: Use saved. Fetch selected. Ignore unselected. -function clearSubscriptions() { +export function clearSubscriptions() { if (pollTimeoutId) { clearTimeout(pollTimeoutId); } @@ -296,7 +296,7 @@ function clearSubscriptions() { * @param settings Settings A way to retrieve the current settings * @param settingsToRetrieve any */ -function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { +export function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { clearSubscriptions(); const mappingPromise = retrieveSettings('fields', settingsToRetrieve); @@ -341,16 +341,3 @@ function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { }, POLL_INTERVAL); }); } - -export default { - getFields, - getTemplates, - getIndices, - getTypes, - loadMappings, - loadAliases, - expandAliases, - clear, - retrieveAutoCompleteInfo, - clearSubscriptions, -}; diff --git a/src/plugins/console/public/lib/utils/__tests__/utils.test.js b/src/plugins/console/public/lib/utils/__tests__/utils.test.js index 6115be3c84ed9..3a2e6a54c1328 100644 --- a/src/plugins/console/public/lib/utils/__tests__/utils.test.js +++ b/src/plugins/console/public/lib/utils/__tests__/utils.test.js @@ -17,7 +17,7 @@ * under the License. */ -const utils = require('../'); +import * as utils from '../'; describe('Utils class', () => { test('extract deprecation messages', function() { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 8346fd900caef..7e25d80c9d619 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -177,6 +177,7 @@ export class DashboardContainer extends Container []) as any, getEmbeddableFactories: start.getEmbeddableFactories, diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html b/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html index f473e91af7ae9..f57c10d1a48dd 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html @@ -8,4 +8,4 @@ listing-limit="listingLimit" hide-write-controls="hideWriteControls" initial-filter="initialFilter" -/> +> diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx index 40231de7597f1..6eb85faeea014 100644 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx @@ -38,6 +38,7 @@ import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { KibanaContextProvider } from '../../../../kibana_react/public'; import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; test('DashboardContainer in edit mode shows edit mode actions', async () => { const inspector = inspectorPluginMock.createStartContract(); @@ -56,7 +57,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW }); const options: DashboardContainerOptions = { - application: {} as any, + application: applicationServiceMock.createStartContract(), embeddable: start, notifications: {} as any, overlays: {} as any, @@ -84,7 +85,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => null) as any} notifications={{} as any} - application={{} as any} + application={options.application} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 0820ebd371004..490ddbed933d9 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -18,7 +18,6 @@ */ export const DashboardConstants = { - ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM: 'addToDashboard', LANDING_PAGE_PATH: '/dashboards', CREATE_NEW_DASHBOARD_URL: '/dashboard', ADD_EMBEDDABLE_ID: 'addEmbeddableId', diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 8e48214ab0f91..d4433f3825fea 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -260,7 +260,6 @@ export { AggregationRestrictions as IndexPatternAggRestrictions, // TODO: exported only in stub_index_pattern test. Move into data plugin and remove export. getIndexPatternFieldListCreator, - Field, } from './index_patterns'; export { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index fab9475d05dc2..cb1e1d2bd0efe 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -14,6 +14,7 @@ import { Component } from 'react'; import { CoreSetup } from 'src/core/public'; import { CoreStart } from 'kibana/public'; import { CoreStart as CoreStart_2 } from 'src/core/public'; +import { Ensure } from '@kbn/utility-types'; import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiComboBoxProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; @@ -429,56 +430,6 @@ export interface FetchOptions { searchStrategyId?: string; } -// Warning: (ae-missing-release-tag) "Field" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -class Field implements IFieldType { - // Warning: (ae-forgotten-export) The symbol "FieldSpec" needs to be exported by the entry point index.d.ts - // - // (undocumented) - $$spec: FieldSpec; - // Warning: (ae-forgotten-export) The symbol "FieldDependencies" needs to be exported by the entry point index.d.ts - constructor(indexPattern: IndexPattern, spec: FieldSpec | Field, shortDotsEnable: boolean, { fieldFormats, toastNotifications }: FieldDependencies); - // (undocumented) - aggregatable?: boolean; - // (undocumented) - conflictDescriptions?: Record; - // (undocumented) - count?: number; - // (undocumented) - displayName?: string; - // (undocumented) - esTypes?: string[]; - // (undocumented) - filterable?: boolean; - // (undocumented) - format: any; - // (undocumented) - indexPattern?: IndexPattern; - // (undocumented) - lang?: string; - // (undocumented) - name: string; - // (undocumented) - script?: string; - // (undocumented) - scripted?: boolean; - // (undocumented) - searchable?: boolean; - // (undocumented) - sortable?: boolean; - // (undocumented) - subType?: IFieldSubType; - // (undocumented) - type: string; - // (undocumented) - visualizable?: boolean; -} - -export { Field } - -export { Field as IndexPatternField } - // Warning: (ae-missing-release-tag) "FieldFormat" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -840,13 +791,15 @@ export interface IIndexPattern { // Warning: (ae-missing-release-tag) "IIndexPatternFieldList" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface IIndexPatternFieldList extends Array { +export interface IIndexPatternFieldList extends Array { + // Warning: (ae-forgotten-export) The symbol "FieldSpec" needs to be exported by the entry point index.d.ts + // // (undocumented) add(field: FieldSpec): void; // (undocumented) - getByName(name: Field['name']): Field | undefined; + getByName(name: IndexPatternField['name']): IndexPatternField | undefined; // (undocumented) - getByType(type: Field['type']): Field[]; + getByType(type: IndexPatternField['type']): IndexPatternField[]; // (undocumented) remove(field: IFieldType): void; // (undocumented) @@ -923,17 +876,17 @@ export class IndexPattern implements IIndexPattern { }[]; }; // (undocumented) - getFieldByName(name: string): Field | void; + getFieldByName(name: string): IndexPatternField | void; // (undocumented) - getNonScriptedFields(): Field[]; + getNonScriptedFields(): IndexPatternField[]; // (undocumented) - getScriptedFields(): Field[]; + getScriptedFields(): IndexPatternField[]; // (undocumented) getSourceFiltering(): { excludes: any[]; }; // (undocumented) - getTimeField(): Field | undefined; + getTimeField(): IndexPatternField | undefined; // (undocumented) id?: string; // (undocumented) @@ -1010,6 +963,50 @@ export interface IndexPatternAttributes { typeMeta: string; } +// Warning: (ae-missing-release-tag) "Field" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class IndexPatternField implements IFieldType { + // (undocumented) + $$spec: FieldSpec; + // Warning: (ae-forgotten-export) The symbol "FieldDependencies" needs to be exported by the entry point index.d.ts + constructor(indexPattern: IndexPattern, spec: FieldSpec | IndexPatternField, shortDotsEnable: boolean, { fieldFormats, toastNotifications }: FieldDependencies); + // (undocumented) + aggregatable?: boolean; + // (undocumented) + conflictDescriptions?: Record; + // (undocumented) + count?: number; + // (undocumented) + displayName?: string; + // (undocumented) + esTypes?: string[]; + // (undocumented) + filterable?: boolean; + // (undocumented) + format: any; + // (undocumented) + indexPattern?: IndexPattern; + // (undocumented) + lang?: string; + // (undocumented) + name: string; + // (undocumented) + script?: string; + // (undocumented) + scripted?: boolean; + // (undocumented) + searchable?: boolean; + // (undocumented) + sortable?: boolean; + // (undocumented) + subType?: IFieldSubType; + // (undocumented) + type: string; + // (undocumented) + visualizable?: boolean; +} + // Warning: (ae-missing-release-tag) "indexPatterns" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1822,20 +1819,20 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:378:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:378:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:378:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:378:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:381:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:404:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:379:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:380:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/aggs/agg_config.ts b/src/plugins/data/public/search/aggs/agg_config.ts index 973c69e3d4f5f..86a2c3e0e82e4 100644 --- a/src/plugins/data/public/search/aggs/agg_config.ts +++ b/src/plugins/data/public/search/aggs/agg_config.ts @@ -19,7 +19,7 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Assign } from '@kbn/utility-types'; +import { Assign, Ensure } from '@kbn/utility-types'; import { ExpressionAstFunction, ExpressionAstArgument } from 'src/plugins/expressions/public'; import { IAggType } from './agg_type'; import { writeParams } from './agg_params'; @@ -31,17 +31,22 @@ import { FieldFormatsStart } from '../../field_formats'; type State = string | number | boolean | null | undefined | SerializableState; -interface SerializableState { +/** @internal **/ +export interface SerializableState { [key: string]: State | State[]; } -export interface AggConfigSerialized { - type: string; - enabled?: boolean; - id?: string; - params?: SerializableState; - schema?: string; -} +/** @internal **/ +export type AggConfigSerialized = Ensure< + { + type: string; + enabled?: boolean; + id?: string; + params?: SerializableState; + schema?: string; + }, + SerializableState +>; export interface AggConfigDependencies { fieldFormats: FieldFormatsStart; diff --git a/src/plugins/data/public/search/aggs/agg_types.ts b/src/plugins/data/public/search/aggs/agg_types.ts index da07f581c9274..2af29d3600246 100644 --- a/src/plugins/data/public/search/aggs/agg_types.ts +++ b/src/plugins/data/public/search/aggs/agg_types.ts @@ -105,6 +105,73 @@ export const getAggTypes = ({ ], }); +/** Buckets: **/ +import { aggFilter } from './buckets/filter_fn'; +import { aggFilters } from './buckets/filters_fn'; +import { aggSignificantTerms } from './buckets/significant_terms_fn'; +import { aggIpRange } from './buckets/ip_range_fn'; +import { aggDateRange } from './buckets/date_range_fn'; +import { aggRange } from './buckets/range_fn'; +import { aggGeoTile } from './buckets/geo_tile_fn'; +import { aggGeoHash } from './buckets/geo_hash_fn'; +import { aggHistogram } from './buckets/histogram_fn'; +import { aggDateHistogram } from './buckets/date_histogram_fn'; import { aggTerms } from './buckets/terms_fn'; -export const getAggTypesFunctions = () => [aggTerms]; +/** Metrics: **/ +import { aggAvg } from './metrics/avg_fn'; +import { aggBucketAvg } from './metrics/bucket_avg_fn'; +import { aggBucketMax } from './metrics/bucket_max_fn'; +import { aggBucketMin } from './metrics/bucket_min_fn'; +import { aggBucketSum } from './metrics/bucket_sum_fn'; +import { aggCardinality } from './metrics/cardinality_fn'; +import { aggCount } from './metrics/count_fn'; +import { aggCumulativeSum } from './metrics/cumulative_sum_fn'; +import { aggDerivative } from './metrics/derivative_fn'; +import { aggGeoBounds } from './metrics/geo_bounds_fn'; +import { aggGeoCentroid } from './metrics/geo_centroid_fn'; +import { aggMax } from './metrics/max_fn'; +import { aggMedian } from './metrics/median_fn'; +import { aggMin } from './metrics/min_fn'; +import { aggMovingAvg } from './metrics/moving_avg_fn'; +import { aggPercentileRanks } from './metrics/percentile_ranks_fn'; +import { aggPercentiles } from './metrics/percentiles_fn'; +import { aggSerialDiff } from './metrics/serial_diff_fn'; +import { aggStdDeviation } from './metrics/std_deviation_fn'; +import { aggSum } from './metrics/sum_fn'; +import { aggTopHit } from './metrics/top_hit_fn'; + +export const getAggTypesFunctions = () => [ + aggAvg, + aggBucketAvg, + aggBucketMax, + aggBucketMin, + aggBucketSum, + aggCardinality, + aggCount, + aggCumulativeSum, + aggDerivative, + aggGeoBounds, + aggGeoCentroid, + aggMax, + aggMedian, + aggMin, + aggMovingAvg, + aggPercentileRanks, + aggPercentiles, + aggSerialDiff, + aggStdDeviation, + aggSum, + aggTopHit, + aggFilter, + aggFilters, + aggSignificantTerms, + aggIpRange, + aggDateRange, + aggRange, + aggGeoTile, + aggGeoHash, + aggDateHistogram, + aggHistogram, + aggTerms, +]; diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts index 3ecdc17cb57f3..219bb5440c8da 100644 --- a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts @@ -27,7 +27,7 @@ import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { createFilterDateHistogram } from './create_filter/date_histogram'; import { intervalOptions } from './_interval_options'; -import { dateHistogramInterval } from '../../../../common'; +import { dateHistogramInterval, TimeRange } from '../../../../common'; import { writeParams } from '../agg_params'; import { isMetricAggType } from '../metrics/metric_agg_type'; @@ -35,6 +35,8 @@ import { FIELD_FORMAT_IDS, KBN_FIELD_TYPES } from '../../../../common'; import { TimefilterContract } from '../../../query'; import { QuerySetup } from '../../../query/query_service'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; +import { ExtendedBounds } from './lib/extended_bounds'; const detectedTimezone = moment.tz.guess(); const tzOffset = moment().format('Z'); @@ -67,6 +69,19 @@ export function isDateHistogramBucketAggConfig(agg: any): agg is IBucketDateHist return Boolean(agg.buckets); } +export interface AggParamsDateHistogram extends BaseAggParams { + field?: string; + timeRange?: TimeRange; + useNormalizedEsInterval?: boolean; + scaleMetricValues?: boolean; + interval?: string; + time_zone?: string; + drop_partials?: boolean; + format?: string; + min_doc_count?: number; + extended_bounds?: ExtendedBounds; +} + export const getDateHistogramBucketAgg = ({ uiSettings, query, @@ -89,6 +104,7 @@ export const getDateHistogramBucketAgg = ({ } const field = agg.getFieldDisplayName(); + return i18n.translate('data.search.aggs.buckets.dateHistogramLabel', { defaultMessage: '{fieldName} per {intervalDescription}', values: { diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.test.ts new file mode 100644 index 0000000000000..bd3c4f8dd58cf --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggDateHistogram } from './date_histogram_fn'; + +describe('agg_expression_functions', () => { + describe('aggDateHistogram', () => { + const fn = functionWrapper(aggDateHistogram()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "drop_partials": undefined, + "extended_bounds": undefined, + "field": undefined, + "format": undefined, + "interval": undefined, + "json": undefined, + "min_doc_count": undefined, + "scaleMetricValues": undefined, + "timeRange": undefined, + "time_zone": undefined, + "useNormalizedEsInterval": undefined, + }, + "schema": undefined, + "type": "date_histogram", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'field', + timeRange: JSON.stringify({ + from: 'from', + to: 'to', + }), + useNormalizedEsInterval: true, + scaleMetricValues: true, + interval: 'interval', + time_zone: 'time_zone', + drop_partials: false, + format: 'format', + min_doc_count: 1, + extended_bounds: JSON.stringify({ + min: 1, + max: 2, + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "drop_partials": false, + "extended_bounds": Object { + "max": 2, + "min": 1, + }, + "field": "field", + "format": "format", + "interval": "interval", + "json": undefined, + "min_doc_count": 1, + "scaleMetricValues": true, + "timeRange": Object { + "from": "from", + "to": "to", + }, + "time_zone": "time_zone", + "useNormalizedEsInterval": true, + }, + "schema": undefined, + "type": "date_histogram", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.ts new file mode 100644 index 0000000000000..033b44da0880f --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.ts @@ -0,0 +1,155 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggDateHistogram'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggDateHistogram = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.dateHistogram.help', { + defaultMessage: 'Generates a serialized agg config for a Histogram agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.dateHistogram.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + useNormalizedEsInterval: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.useNormalizedEsInterval.help', { + defaultMessage: 'Specifies whether to use useNormalizedEsInterval for this aggregation', + }), + }, + time_zone: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.timeZone.help', { + defaultMessage: 'Time zone to use for this aggregation', + }), + }, + format: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.format.help', { + defaultMessage: 'Format to use for this aggregation', + }), + }, + scaleMetricValues: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.scaleMetricValues.help', { + defaultMessage: 'Specifies whether to use scaleMetricValues for this aggregation', + }), + }, + interval: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.interval.help', { + defaultMessage: 'Interval to use for this aggregation', + }), + }, + timeRange: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.timeRange.help', { + defaultMessage: 'Time Range to use for this aggregation', + }), + }, + min_doc_count: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.minDocCount.help', { + defaultMessage: 'Minimum document count to use for this aggregation', + }), + }, + drop_partials: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.dropPartials.help', { + defaultMessage: 'Specifies whether to use drop_partials for this aggregation', + }), + }, + extended_bounds: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.extendedBounds.help', { + defaultMessage: + 'With extended_bounds setting, you now can "force" the histogram aggregation to start building buckets on a specific min value and also keep on building buckets up to a max value ', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.DATE_HISTOGRAM, + params: { + ...rest, + timeRange: getParsedValue(args, 'timeRange'), + extended_bounds: getParsedValue(args, 'extended_bounds'), + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/date_range.ts b/src/plugins/data/public/search/aggs/buckets/date_range.ts index 07d927e64a943..504958854cad4 100644 --- a/src/plugins/data/public/search/aggs/buckets/date_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/date_range.ts @@ -29,6 +29,7 @@ import { convertDateRangeToString, DateRangeKey } from './lib/date_range'; import { KBN_FIELD_TYPES, FieldFormat, TEXT_CONTEXT_TYPE } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const dateRangeTitle = i18n.translate('data.search.aggs.buckets.dateRangeTitle', { defaultMessage: 'Date Range', @@ -39,6 +40,12 @@ export interface DateRangeBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsDateRange extends BaseAggParams { + field?: string; + ranges?: DateRangeKey[]; + time_zone?: string; +} + export const getDateRangeBucketAgg = ({ uiSettings, getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/buckets/date_range_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/date_range_fn.test.ts new file mode 100644 index 0000000000000..93bb791874e67 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/date_range_fn.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggDateRange } from './date_range_fn'; + +describe('agg_expression_functions', () => { + describe('aggDateRange', () => { + const fn = functionWrapper(aggDateRange()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": undefined, + "json": undefined, + "ranges": undefined, + "time_zone": undefined, + }, + "schema": undefined, + "type": "date_range", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'date_field', + time_zone: 'UTC +3', + ranges: JSON.stringify([ + { from: 'now-1w/w', to: 'now' }, + { from: 1588163532470, to: 1588163532481 }, + ]), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "date_field", + "json": undefined, + "ranges": Array [ + Object { + "from": "now-1w/w", + "to": "now", + }, + Object { + "from": 1588163532470, + "to": 1588163532481, + }, + ], + "time_zone": "UTC +3", + }, + "schema": undefined, + "type": "date_range", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'date_field', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'date_field', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/date_range_fn.ts b/src/plugins/data/public/search/aggs/buckets/date_range_fn.ts new file mode 100644 index 0000000000000..1fe42ce63d815 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/date_range_fn.ts @@ -0,0 +1,111 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggDateRange'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggDateRange = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.dateRange.help', { + defaultMessage: 'Generates a serialized agg config for a Date Range agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.dateRange.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + ranges: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.ranges.help', { + defaultMessage: 'Serialized ranges to use for this aggregation.', + }), + }, + time_zone: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.timeZone.help', { + defaultMessage: 'Time zone to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.DATE_RANGE, + params: { + ...rest, + json: getParsedValue(args, 'json'), + ranges: getParsedValue(args, 'ranges'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/filter.ts b/src/plugins/data/public/search/aggs/buckets/filter.ts index accbdf4dd783d..69157edad4f68 100644 --- a/src/plugins/data/public/search/aggs/buckets/filter.ts +++ b/src/plugins/data/public/search/aggs/buckets/filter.ts @@ -21,6 +21,8 @@ import { i18n } from '@kbn/i18n'; import { BucketAggType } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { GetInternalStartServicesFn } from '../../../types'; +import { GeoBoundingBox } from './lib/geo_point'; +import { BaseAggParams } from '../types'; const filterTitle = i18n.translate('data.search.aggs.buckets.filterTitle', { defaultMessage: 'Filter', @@ -30,6 +32,10 @@ export interface FilterBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsFilter extends BaseAggParams { + geo_bounding_box?: GeoBoundingBox; +} + export const getFilterBucketAgg = ({ getInternalStartServices }: FilterBucketAggDependencies) => new BucketAggType( { diff --git a/src/plugins/data/public/search/aggs/buckets/filter_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/filter_fn.test.ts new file mode 100644 index 0000000000000..c820a73b0a894 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/filter_fn.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggFilter } from './filter_fn'; + +describe('agg_expression_functions', () => { + describe('aggFilter', () => { + const fn = functionWrapper(aggFilter()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "geo_bounding_box": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "filter", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + geo_bounding_box: JSON.stringify({ + wkt: 'BBOX (-74.1, -71.12, 40.73, 40.01)', + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "geo_bounding_box": Object { + "wkt": "BBOX (-74.1, -71.12, 40.73, 40.01)", + }, + "json": undefined, + }, + "schema": undefined, + "type": "filter", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/filter_fn.ts b/src/plugins/data/public/search/aggs/buckets/filter_fn.ts new file mode 100644 index 0000000000000..4a7180fc86c71 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/filter_fn.ts @@ -0,0 +1,99 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggFilter'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggFilter = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.filter.help', { + defaultMessage: 'Generates a serialized agg config for a Filter agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.filter.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + geo_bounding_box: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.geoBoundingBox.help', { + defaultMessage: 'Filter results based on a point location within a bounding box', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.FILTER, + params: { + ...rest, + json: getParsedValue(args, 'json'), + geo_bounding_box: getParsedValue(args, 'geo_bounding_box'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/filters.ts b/src/plugins/data/public/search/aggs/buckets/filters.ts index fe013928bba65..8654645d46a9b 100644 --- a/src/plugins/data/public/search/aggs/buckets/filters.ts +++ b/src/plugins/data/public/search/aggs/buckets/filters.ts @@ -29,6 +29,7 @@ import { Storage } from '../../../../../../plugins/kibana_utils/public'; import { getEsQueryConfig, buildEsQuery, Query } from '../../../../common'; import { getQueryLog } from '../../../query'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const filtersTitle = i18n.translate('data.search.aggs.buckets.filtersTitle', { defaultMessage: 'Filters', @@ -47,6 +48,13 @@ export interface FiltersBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsFilters extends Omit { + filters?: Array<{ + input: Query; + label: string; + }>; +} + export const getFiltersBucketAgg = ({ uiSettings, getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/buckets/filters_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/filters_fn.test.ts new file mode 100644 index 0000000000000..99c4f7d8c2b65 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/filters_fn.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggFilters } from './filters_fn'; + +describe('agg_expression_functions', () => { + describe('aggFilters', () => { + const fn = functionWrapper(aggFilters()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "filters": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "filters", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + filters: JSON.stringify([ + { + query: 'query', + language: 'lucene', + label: 'test', + }, + ]), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "filters": Array [ + Object { + "label": "test", + "language": "lucene", + "query": "query", + }, + ], + "json": undefined, + }, + "schema": undefined, + "type": "filters", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/filters_fn.ts b/src/plugins/data/public/search/aggs/buckets/filters_fn.ts new file mode 100644 index 0000000000000..6ffd5369d7087 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/filters_fn.ts @@ -0,0 +1,93 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggFilters'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggFilters = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.filters.help', { + defaultMessage: 'Generates a serialized agg config for a Filter agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filters.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.filters.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filters.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + filters: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filters.filters.help', { + defaultMessage: 'Filters to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filters.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.FILTERS, + params: { + ...rest, + filters: getParsedValue(args, 'filters'), + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/geo_hash.ts b/src/plugins/data/public/search/aggs/buckets/geo_hash.ts index eab10edad60f6..be339de5d7fae 100644 --- a/src/plugins/data/public/search/aggs/buckets/geo_hash.ts +++ b/src/plugins/data/public/search/aggs/buckets/geo_hash.ts @@ -22,6 +22,8 @@ import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { KBN_FIELD_TYPES } from '../../../../common'; import { BUCKET_TYPES } from './bucket_agg_types'; import { GetInternalStartServicesFn } from '../../../types'; +import { GeoBoundingBox } from './lib/geo_point'; +import { BaseAggParams } from '../types'; const defaultBoundingBox = { top_left: { lat: 1, lon: 1 }, @@ -38,6 +40,15 @@ export interface GeoHashBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsGeoHash extends BaseAggParams { + field: string; + autoPrecision?: boolean; + precision?: number; + useGeocentroid?: boolean; + isFilteredByCollar?: boolean; + boundingBox?: GeoBoundingBox; +} + export const getGeoHashBucketAgg = ({ getInternalStartServices }: GeoHashBucketAggDependencies) => new BucketAggType( { diff --git a/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.test.ts new file mode 100644 index 0000000000000..07ab8e66f1def --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggGeoHash } from './geo_hash_fn'; + +describe('agg_expression_functions', () => { + describe('aggGeoHash', () => { + const fn = functionWrapper(aggGeoHash()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'geo_field', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "autoPrecision": undefined, + "boundingBox": undefined, + "customLabel": undefined, + "field": "geo_field", + "isFilteredByCollar": undefined, + "json": undefined, + "precision": undefined, + "useGeocentroid": undefined, + }, + "schema": undefined, + "type": "geohash_grid", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'geo_field', + autoPrecision: false, + precision: 10, + useGeocentroid: true, + isFilteredByCollar: false, + boundingBox: JSON.stringify({ + top_left: [-74.1, 40.73], + bottom_right: [-71.12, 40.01], + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "autoPrecision": false, + "boundingBox": Object { + "bottom_right": Array [ + -71.12, + 40.01, + ], + "top_left": Array [ + -74.1, + 40.73, + ], + }, + "customLabel": undefined, + "field": "geo_field", + "isFilteredByCollar": false, + "json": undefined, + "precision": 10, + "useGeocentroid": true, + }, + "schema": undefined, + "type": "geohash_grid", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'geo_field', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'geo_field', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.ts b/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.ts new file mode 100644 index 0000000000000..bbfa8575d486c --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.ts @@ -0,0 +1,129 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggGeoHash'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggGeoHash = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.geoHash.help', { + defaultMessage: 'Generates a serialized agg config for a Geo Hash agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.geoHash.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.geoHash.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + useGeocentroid: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.geoHash.useGeocentroid.help', { + defaultMessage: 'Specifies whether to use geocentroid for this aggregation', + }), + }, + autoPrecision: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.geoHash.autoPrecision.help', { + defaultMessage: 'Specifies whether to use auto precision for this aggregation', + }), + }, + isFilteredByCollar: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.geoHash.isFilteredByCollar.help', { + defaultMessage: 'Specifies whether to filter by collar', + }), + }, + boundingBox: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.boundingBox.help', { + defaultMessage: 'Filter results based on a point location within a bounding box', + }), + }, + precision: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.geoHash.precision.help', { + defaultMessage: 'Precision to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.GEOHASH_GRID, + params: { + ...rest, + boundingBox: getParsedValue(args, 'boundingBox'), + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/geo_tile.ts b/src/plugins/data/public/search/aggs/buckets/geo_tile.ts index c981e8400f9a1..1212bba23a93a 100644 --- a/src/plugins/data/public/search/aggs/buckets/geo_tile.ts +++ b/src/plugins/data/public/search/aggs/buckets/geo_tile.ts @@ -25,6 +25,7 @@ import { BUCKET_TYPES } from './bucket_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { METRIC_TYPES } from '../metrics/metric_agg_types'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; export interface GeoTitleBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; @@ -34,6 +35,12 @@ const geotileGridTitle = i18n.translate('data.search.aggs.buckets.geotileGridTit defaultMessage: 'Geotile', }); +export interface AggParamsGeoTile extends BaseAggParams { + field: string; + useGeocentroid?: boolean; + precision?: number; +} + export const getGeoTitleBucketAgg = ({ getInternalStartServices }: GeoTitleBucketAggDependencies) => new BucketAggType( { diff --git a/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.test.ts new file mode 100644 index 0000000000000..bfaf47ede8734 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggGeoTile } from './geo_tile_fn'; + +describe('agg_expression_functions', () => { + describe('aggGeoTile', () => { + const fn = functionWrapper(aggGeoTile()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'geo_field', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "geo_field", + "json": undefined, + "precision": undefined, + "useGeocentroid": undefined, + }, + "schema": undefined, + "type": "geotile_grid", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'geo_field', + useGeocentroid: false, + precision: 10, + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "geo_field", + "json": undefined, + "precision": 10, + "useGeocentroid": false, + }, + "schema": undefined, + "type": "geotile_grid", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'geo_field', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'geo_field', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.ts b/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.ts new file mode 100644 index 0000000000000..9c33ef45762af --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.ts @@ -0,0 +1,108 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggGeoTile'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggGeoTile = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.geoTile.help', { + defaultMessage: 'Generates a serialized agg config for a Geo Tile agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoTile.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.geoTile.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoTile.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.geoTile.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + useGeocentroid: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.geoTile.useGeocentroid.help', { + defaultMessage: 'Specifies whether to use geocentroid for this aggregation', + }), + }, + precision: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.geoTile.precision.help', { + defaultMessage: 'Precision to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoTile.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoTile.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.GEOTILE_GRID, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/histogram.ts b/src/plugins/data/public/search/aggs/buckets/histogram.ts index f8e8720d24ea9..d04df4f8aac6b 100644 --- a/src/plugins/data/public/search/aggs/buckets/histogram.ts +++ b/src/plugins/data/public/search/aggs/buckets/histogram.ts @@ -26,6 +26,8 @@ import { createFilterHistogram } from './create_filter/histogram'; import { BUCKET_TYPES } from './bucket_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; +import { ExtendedBounds } from './lib/extended_bounds'; export interface AutoBounds { min: number; @@ -42,6 +44,15 @@ export interface IBucketHistogramAggConfig extends IBucketAggConfig { getAutoBounds: () => AutoBounds; } +export interface AggParamsHistogram extends BaseAggParams { + field: string; + interval: string; + intervalBase?: number; + min_doc_count?: boolean; + has_extended_bounds?: boolean; + extended_bounds?: ExtendedBounds; +} + export const getHistogramBucketAgg = ({ uiSettings, getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/buckets/histogram_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/histogram_fn.test.ts new file mode 100644 index 0000000000000..34b6fa1a6dcd6 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/histogram_fn.test.ts @@ -0,0 +1,109 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggHistogram } from './histogram_fn'; + +describe('agg_expression_functions', () => { + describe('aggHistogram', () => { + const fn = functionWrapper(aggHistogram()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'field', + interval: '10', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "extended_bounds": undefined, + "field": "field", + "has_extended_bounds": undefined, + "interval": "10", + "intervalBase": undefined, + "json": undefined, + "min_doc_count": undefined, + }, + "schema": undefined, + "type": "histogram", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'field', + interval: '10', + intervalBase: 1, + min_doc_count: false, + has_extended_bounds: false, + extended_bounds: JSON.stringify({ + min: 1, + max: 2, + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "extended_bounds": Object { + "max": 2, + "min": 1, + }, + "field": "field", + "has_extended_bounds": false, + "interval": "10", + "intervalBase": 1, + "json": undefined, + "min_doc_count": false, + }, + "schema": undefined, + "type": "histogram", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'field', + interval: '10', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'field', + interval: '10', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/histogram_fn.ts b/src/plugins/data/public/search/aggs/buckets/histogram_fn.ts new file mode 100644 index 0000000000000..1e5a5a72c0ecb --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/histogram_fn.ts @@ -0,0 +1,132 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggHistogram'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggHistogram = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.histogram.help', { + defaultMessage: 'Generates a serialized agg config for a Histogram agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.histogram.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.histogram.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + interval: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.histogram.interval.help', { + defaultMessage: 'Interval to use for this aggregation', + }), + }, + intervalBase: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.histogram.intervalBase.help', { + defaultMessage: 'IntervalBase to use for this aggregation', + }), + }, + min_doc_count: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.histogram.minDocCount.help', { + defaultMessage: 'Specifies whether to use min_doc_count for this aggregation', + }), + }, + has_extended_bounds: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.histogram.hasExtendedBounds.help', { + defaultMessage: 'Specifies whether to use has_extended_bounds for this aggregation', + }), + }, + extended_bounds: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.extendedBounds.help', { + defaultMessage: + 'With extended_bounds setting, you now can "force" the histogram aggregation to start building buckets on a specific min value and also keep on building buckets up to a max value ', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.HISTOGRAM, + params: { + ...rest, + extended_bounds: getParsedValue(args, 'extended_bounds'), + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/index.ts b/src/plugins/data/public/search/aggs/buckets/index.ts index 3a402b1498a77..7036cc7785db7 100644 --- a/src/plugins/data/public/search/aggs/buckets/index.ts +++ b/src/plugins/data/public/search/aggs/buckets/index.ts @@ -19,11 +19,18 @@ export * from './_interval_options'; export * from './bucket_agg_types'; +export * from './histogram'; export * from './date_histogram'; export * from './date_range'; +export * from './range'; +export * from './filter'; +export * from './filters'; +export * from './geo_tile'; +export * from './geo_hash'; export * from './ip_range'; export * from './lib/cidr_mask'; export * from './lib/date_range'; export * from './lib/ip_range'; export * from './migrate_include_exclude_format'; +export * from './significant_terms'; export * from './terms'; diff --git a/src/plugins/data/public/search/aggs/buckets/ip_range.ts b/src/plugins/data/public/search/aggs/buckets/ip_range.ts index bde347d6e673d..029fd864154be 100644 --- a/src/plugins/data/public/search/aggs/buckets/ip_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/ip_range.ts @@ -23,18 +23,38 @@ import { BucketAggType } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { createFilterIpRange } from './create_filter/ip_range'; -import { IpRangeKey, convertIPRangeToString } from './lib/ip_range'; +import { + convertIPRangeToString, + IpRangeKey, + RangeIpRangeAggKey, + CidrMaskIpRangeAggKey, +} from './lib/ip_range'; import { KBN_FIELD_TYPES, FieldFormat, TEXT_CONTEXT_TYPE } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const ipRangeTitle = i18n.translate('data.search.aggs.buckets.ipRangeTitle', { defaultMessage: 'IPv4 Range', }); +export enum IP_RANGE_TYPES { + FROM_TO = 'fromTo', + MASK = 'mask', +} + export interface IpRangeBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsIpRange extends BaseAggParams { + field: string; + ipRangeType?: IP_RANGE_TYPES; + ranges?: Partial<{ + [IP_RANGE_TYPES.FROM_TO]: RangeIpRangeAggKey[]; + [IP_RANGE_TYPES.MASK]: CidrMaskIpRangeAggKey[]; + }>; +} + export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketAggDependencies) => new BucketAggType( { @@ -42,7 +62,7 @@ export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketA title: ipRangeTitle, createFilter: createFilterIpRange, getKey(bucket, key, agg): IpRangeKey { - if (agg.params.ipRangeType === 'mask') { + if (agg.params.ipRangeType === IP_RANGE_TYPES.MASK) { return { type: 'mask', mask: key }; } return { type: 'range', from: bucket.from, to: bucket.to }; @@ -74,7 +94,7 @@ export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketA }, { name: 'ipRangeType', - default: 'fromTo', + default: IP_RANGE_TYPES.FROM_TO, write: noop, }, { @@ -90,7 +110,7 @@ export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketA const ipRangeType = aggConfig.params.ipRangeType; let ranges = aggConfig.params.ranges[ipRangeType]; - if (ipRangeType === 'fromTo') { + if (ipRangeType === IP_RANGE_TYPES.FROM_TO) { ranges = map(ranges, (range: any) => omit(range, isNull)); } diff --git a/src/plugins/data/public/search/aggs/buckets/ip_range_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/ip_range_fn.test.ts new file mode 100644 index 0000000000000..5940345b25890 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/ip_range_fn.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { IP_RANGE_TYPES } from './ip_range'; +import { aggIpRange } from './ip_range_fn'; + +describe('agg_expression_functions', () => { + describe('aggIpRange', () => { + const fn = functionWrapper(aggIpRange()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'ip_field', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "ip_field", + "ipRangeType": undefined, + "json": undefined, + "ranges": undefined, + }, + "schema": undefined, + "type": "ip_range", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'ip_field', + ipRangeType: IP_RANGE_TYPES.MASK, + ranges: JSON.stringify({ + mask: [{ mask: '10.0.0.0/25' }], + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "ip_field", + "ipRangeType": "mask", + "json": undefined, + "ranges": Object { + "mask": Array [ + Object { + "mask": "10.0.0.0/25", + }, + ], + }, + }, + "schema": undefined, + "type": "ip_range", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'ip_field', + ipRangeType: IP_RANGE_TYPES.MASK, + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'ip_field', + ipRangeType: IP_RANGE_TYPES.FROM_TO, + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/ip_range_fn.ts b/src/plugins/data/public/search/aggs/buckets/ip_range_fn.ts new file mode 100644 index 0000000000000..554a8708d9164 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/ip_range_fn.ts @@ -0,0 +1,114 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggIpRange'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggIpRange = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.ipRange.help', { + defaultMessage: 'Generates a serialized agg config for a Ip Range agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.ipRange.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.ipRange.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + ipRangeType: { + types: ['string'], + options: ['mask', 'fromTo'], + help: i18n.translate('data.search.aggs.buckets.ipRange.ipRangeType.help', { + defaultMessage: + 'IP range type to use for this aggregation. Takes one of the following values: mask, fromTo.', + }), + }, + ranges: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.ranges.help', { + defaultMessage: 'Serialized ranges to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.IP_RANGE, + params: { + ...rest, + json: getParsedValue(args, 'json'), + ranges: getParsedValue(args, 'ranges'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/lib/date_range.ts b/src/plugins/data/public/search/aggs/buckets/lib/date_range.ts index 6eb9fe8414ec8..d52bdff993a2b 100644 --- a/src/plugins/data/public/search/aggs/buckets/lib/date_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/lib/date_range.ts @@ -18,8 +18,8 @@ */ export interface DateRangeKey { - from: number; - to: number; + from: number | string; + to: number | string; } export function convertDateRangeToString({ from, to }: DateRangeKey, format: (val: any) => string) { diff --git a/test/functional/page_objects/monitoring_page.js b/src/plugins/data/public/search/aggs/buckets/lib/extended_bounds.ts similarity index 67% rename from test/functional/page_objects/monitoring_page.js rename to src/plugins/data/public/search/aggs/buckets/lib/extended_bounds.ts index 7dab9dc3e52b1..7a249a9daca91 100644 --- a/test/functional/page_objects/monitoring_page.js +++ b/src/plugins/data/public/search/aggs/buckets/lib/extended_bounds.ts @@ -17,19 +17,7 @@ * under the License. */ -export function MonitoringPageProvider({ getService }) { - const find = getService('find'); - - class MonitoringPage { - async getWelcome() { - const el = await find.displayedByCssSelector('render-directive'); - return await el.getVisibleText(); - } - - async clickOptOut() { - return find.clickByLinkText('Opt out here'); - } - } - - return new MonitoringPage(); +export interface ExtendedBounds { + min: number; + max: number; } diff --git a/src/plugins/data/public/search/aggs/buckets/lib/geo_point.ts b/src/plugins/data/public/search/aggs/buckets/lib/geo_point.ts new file mode 100644 index 0000000000000..8ff4493e286cf --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/lib/geo_point.ts @@ -0,0 +1,86 @@ +/* + * 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. + */ + +type GeoPoint = + | { + lat: number; + lon: number; + } + | string + | [number, number]; + +interface GeoBox { + top: number; + left: number; + bottom: number; + right: number; +} + +/** GeoBoundingBox Accepted Formats: + * Lat Lon As Properties: + * "top_left" : { + * "lat" : 40.73, "lon" : -74.1 + * }, + * "bottom_right" : { + * "lat" : 40.01, "lon" : -71.12 + * } + * + * Lat Lon As Array: + * { + * "top_left" : [-74.1, 40.73], + * "bottom_right" : [-71.12, 40.01] + * } + * + * Lat Lon As String: + * { + * "top_left" : "40.73, -74.1", + * "bottom_right" : "40.01, -71.12" + * } + * + * Bounding Box as Well-Known Text (WKT): + * { + * "wkt" : "BBOX (-74.1, -71.12, 40.73, 40.01)" + * } + * + * Geohash: + * { + * "top_right" : "dr5r9ydj2y73", + * "bottom_left" : "drj7teegpus6" + * } + * + * Vertices: + * { + * "top" : 40.73, + * "left" : -74.1, + * "bottom" : 40.01, + * "right" : -71.12 + * } + * + * **/ +export type GeoBoundingBox = + | Partial<{ + top_left: GeoPoint; + top_right: GeoPoint; + bottom_right: GeoPoint; + bottom_left: GeoPoint; + }> + | { + wkt: string; + } + | GeoBox; diff --git a/src/plugins/data/public/search/aggs/buckets/lib/ip_range.ts b/src/plugins/data/public/search/aggs/buckets/lib/ip_range.ts index be1ac28934c7c..57e5337d4c365 100644 --- a/src/plugins/data/public/search/aggs/buckets/lib/ip_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/lib/ip_range.ts @@ -17,9 +17,18 @@ * under the License. */ -export type IpRangeKey = - | { type: 'mask'; mask: string } - | { type: 'range'; from: string; to: string }; +export interface CidrMaskIpRangeAggKey { + type: 'mask'; + mask: string; +} + +export interface RangeIpRangeAggKey { + type: 'range'; + from: string; + to: string; +} + +export type IpRangeKey = CidrMaskIpRangeAggKey | RangeIpRangeAggKey; export const convertIPRangeToString = (range: IpRangeKey, format: (val: any) => string) => { if (range.type === 'mask') { diff --git a/src/plugins/data/public/search/aggs/buckets/range.ts b/src/plugins/data/public/search/aggs/buckets/range.ts index 2c1303814a88a..02aad3bd5fed1 100644 --- a/src/plugins/data/public/search/aggs/buckets/range.ts +++ b/src/plugins/data/public/search/aggs/buckets/range.ts @@ -24,6 +24,7 @@ import { RangeKey } from './range_key'; import { createFilterRange } from './create_filter/range'; import { BUCKET_TYPES } from './bucket_agg_types'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const keyCaches = new WeakMap(); const formats = new WeakMap(); @@ -36,6 +37,14 @@ export interface RangeBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsRange extends BaseAggParams { + field: string; + ranges?: Array<{ + from: number; + to: number; + }>; +} + export const getRangeBucketAgg = ({ getInternalStartServices }: RangeBucketAggDependencies) => new BucketAggType( { diff --git a/src/plugins/data/public/search/aggs/buckets/range_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/range_fn.test.ts new file mode 100644 index 0000000000000..93ae4490196a8 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/range_fn.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggRange } from './range_fn'; + +describe('agg_expression_functions', () => { + describe('aggRange', () => { + const fn = functionWrapper(aggRange()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'number_field', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "number_field", + "json": undefined, + "ranges": undefined, + }, + "schema": undefined, + "type": "range", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'number_field', + ranges: JSON.stringify([ + { from: 1, to: 2 }, + { from: 5, to: 100 }, + ]), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "number_field", + "json": undefined, + "ranges": Array [ + Object { + "from": 1, + "to": 2, + }, + Object { + "from": 5, + "to": 100, + }, + ], + }, + "schema": undefined, + "type": "range", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'number_field', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'number_field', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/range_fn.ts b/src/plugins/data/public/search/aggs/buckets/range_fn.ts new file mode 100644 index 0000000000000..48686e7061de9 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/range_fn.ts @@ -0,0 +1,106 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggRange'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggRange = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.range.help', { + defaultMessage: 'Generates a serialized agg config for a Range agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.range.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.range.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + ranges: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.ranges.help', { + defaultMessage: 'Serialized ranges to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.RANGE, + params: { + ...rest, + json: getParsedValue(args, 'json'), + ranges: getParsedValue(args, 'ranges'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/significant_terms.ts b/src/plugins/data/public/search/aggs/buckets/significant_terms.ts index 49d797f3afbc9..e6afc56dfd31c 100644 --- a/src/plugins/data/public/search/aggs/buckets/significant_terms.ts +++ b/src/plugins/data/public/search/aggs/buckets/significant_terms.ts @@ -24,6 +24,7 @@ import { isStringType, migrateIncludeExcludeFormat } from './migrate_include_exc import { BUCKET_TYPES } from './bucket_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const significantTermsTitle = i18n.translate('data.search.aggs.buckets.significantTermsTitle', { defaultMessage: 'Significant Terms', @@ -33,6 +34,13 @@ export interface SignificantTermsBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsSignificantTerms extends BaseAggParams { + field: string; + size?: number; + exclude?: string; + include?: string; +} + export const getSignificantTermsBucketAgg = ({ getInternalStartServices, }: SignificantTermsBucketAggDependencies) => diff --git a/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.test.ts new file mode 100644 index 0000000000000..71be4e9cfa9ac --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.test.ts @@ -0,0 +1,96 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggSignificantTerms } from './significant_terms_fn'; + +describe('agg_expression_functions', () => { + describe('aggSignificantTerms', () => { + const fn = functionWrapper(aggSignificantTerms()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "exclude": undefined, + "field": "machine.os.keyword", + "include": undefined, + "json": undefined, + "size": undefined, + }, + "schema": undefined, + "type": "significant_terms", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + id: '1', + enabled: false, + schema: 'whatever', + field: 'machine.os.keyword', + size: 6, + include: 'win', + exclude: 'ios', + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": false, + "id": "1", + "params": Object { + "customLabel": undefined, + "exclude": "ios", + "field": "machine.os.keyword", + "include": "win", + "json": undefined, + "size": 6, + }, + "schema": "whatever", + "type": "significant_terms", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.ts b/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.ts new file mode 100644 index 0000000000000..83583070bddfe --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.ts @@ -0,0 +1,116 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggSignificantTerms'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = AggArgs; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggSignificantTerms = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.significantTerms.help', { + defaultMessage: 'Generates a serialized agg config for a Significant Terms agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.significantTerms.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.significantTerms.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + size: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.size.help', { + defaultMessage: 'Max number of buckets to retrieve', + }), + }, + exclude: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.exclude.help', { + defaultMessage: 'Specific bucket values to exclude from results', + }), + }, + include: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.include.help', { + defaultMessage: 'Specific bucket values to include in results', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.SIGNIFICANT_TERMS, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/terms.ts b/src/plugins/data/public/search/aggs/buckets/terms.ts index a12a1d7ac2d3d..1bfc508dc3871 100644 --- a/src/plugins/data/public/search/aggs/buckets/terms.ts +++ b/src/plugins/data/public/search/aggs/buckets/terms.ts @@ -26,7 +26,7 @@ import { isStringOrNumberType, migrateIncludeExcludeFormat, } from './migrate_include_exclude_format'; -import { AggConfigSerialized, IAggConfigs } from '../types'; +import { AggConfigSerialized, BaseAggParams, IAggConfigs } from '../types'; import { Adapters } from '../../../../../inspector/public'; import { ISearchSource } from '../../search_source'; @@ -63,11 +63,11 @@ export interface TermsBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } -export interface AggParamsTerms { +export interface AggParamsTerms extends BaseAggParams { field: string; - order: 'asc' | 'desc'; orderBy: string; orderAgg?: AggConfigSerialized; + order?: 'asc' | 'desc'; size?: number; missingBucket?: boolean; missingBucketLabel?: string; @@ -76,7 +76,6 @@ export interface AggParamsTerms { // advanced exclude?: string; include?: string; - json?: string; } export const getTermsBucketAgg = ({ getInternalStartServices }: TermsBucketAggDependencies) => diff --git a/src/plugins/data/public/search/aggs/buckets/terms_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/terms_fn.test.ts index f55f1de796013..1384a9f17e4b6 100644 --- a/src/plugins/data/public/search/aggs/buckets/terms_fn.test.ts +++ b/src/plugins/data/public/search/aggs/buckets/terms_fn.test.ts @@ -27,7 +27,6 @@ describe('agg_expression_functions', () => { test('fills in defaults when only required args are provided', () => { const actual = fn({ field: 'machine.os.keyword', - order: 'asc', orderBy: '1', }); expect(actual).toMatchInlineSnapshot(` @@ -37,18 +36,19 @@ describe('agg_expression_functions', () => { "enabled": true, "id": undefined, "params": Object { + "customLabel": undefined, "exclude": undefined, "field": "machine.os.keyword", "include": undefined, "json": undefined, - "missingBucket": false, - "missingBucketLabel": "Missing", - "order": "asc", + "missingBucket": undefined, + "missingBucketLabel": undefined, + "order": undefined, "orderAgg": undefined, "orderBy": "1", - "otherBucket": false, - "otherBucketLabel": "Other", - "size": 5, + "otherBucket": undefined, + "otherBucketLabel": undefined, + "size": undefined, }, "schema": undefined, "type": "terms", @@ -70,6 +70,7 @@ describe('agg_expression_functions', () => { missingBucketLabel: 'missing', otherBucket: true, otherBucketLabel: 'other', + include: 'win', exclude: 'ios', }); @@ -78,9 +79,10 @@ describe('agg_expression_functions', () => { "enabled": false, "id": "1", "params": Object { + "customLabel": undefined, "exclude": "ios", "field": "machine.os.keyword", - "include": undefined, + "include": "win", "json": undefined, "missingBucket": true, "missingBucketLabel": "missing", @@ -107,37 +109,39 @@ describe('agg_expression_functions', () => { expect(actual.value.params).toMatchInlineSnapshot(` Object { + "customLabel": undefined, "exclude": undefined, "field": "machine.os.keyword", "include": undefined, "json": undefined, - "missingBucket": false, - "missingBucketLabel": "Missing", + "missingBucket": undefined, + "missingBucketLabel": undefined, "order": "asc", "orderAgg": Object { "enabled": true, "id": undefined, "params": Object { + "customLabel": undefined, "exclude": undefined, "field": "name", "include": undefined, "json": undefined, - "missingBucket": false, - "missingBucketLabel": "Missing", + "missingBucket": undefined, + "missingBucketLabel": undefined, "order": "asc", "orderAgg": undefined, "orderBy": "1", - "otherBucket": false, - "otherBucketLabel": "Other", - "size": 5, + "otherBucket": undefined, + "otherBucketLabel": undefined, + "size": undefined, }, "schema": undefined, "type": "terms", }, "orderBy": "1", - "otherBucket": false, - "otherBucketLabel": "Other", - "size": 5, + "otherBucket": undefined, + "otherBucketLabel": undefined, + "size": undefined, } `); }); diff --git a/src/plugins/data/public/search/aggs/buckets/terms_fn.ts b/src/plugins/data/public/search/aggs/buckets/terms_fn.ts index 7980bfabe79fb..49520863fe1cc 100644 --- a/src/plugins/data/public/search/aggs/buckets/terms_fn.ts +++ b/src/plugins/data/public/search/aggs/buckets/terms_fn.ts @@ -20,27 +20,25 @@ import { i18n } from '@kbn/i18n'; import { Assign } from '@kbn/utility-types'; import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; -import { AggExpressionType, AggExpressionFunctionArgs } from '../'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; -const aggName = 'terms'; const fnName = 'aggTerms'; type Input = any; -type AggArgs = AggExpressionFunctionArgs; +type AggArgs = AggExpressionFunctionArgs; + // Since the orderAgg param is an agg nested in a subexpression, we need to // overwrite the param type to expect a value of type AggExpressionType. -type Arguments = AggArgs & - Assign< - AggArgs, - { orderAgg?: AggArgs['orderAgg'] extends undefined ? undefined : AggExpressionType } - >; +type Arguments = Assign; + type Output = AggExpressionType; type FunctionDefinition = ExpressionFunctionDefinition; export const aggTerms = (): FunctionDefinition => ({ name: fnName, help: i18n.translate('data.search.aggs.function.buckets.terms.help', { - defaultMessage: 'Generates a serialized agg config for a terms agg', + defaultMessage: 'Generates a serialized agg config for a Terms agg', }), type: 'agg_type', args: { @@ -72,7 +70,7 @@ export const aggTerms = (): FunctionDefinition => ({ }, order: { types: ['string'], - required: true, + options: ['asc', 'desc'], help: i18n.translate('data.search.aggs.buckets.terms.order.help', { defaultMessage: 'Order in which to return the results: asc or desc', }), @@ -91,41 +89,30 @@ export const aggTerms = (): FunctionDefinition => ({ }, size: { types: ['number'], - default: 5, help: i18n.translate('data.search.aggs.buckets.terms.size.help', { defaultMessage: 'Max number of buckets to retrieve', }), }, missingBucket: { types: ['boolean'], - default: false, help: i18n.translate('data.search.aggs.buckets.terms.missingBucket.help', { defaultMessage: 'When set to true, groups together any buckets with missing fields', }), }, missingBucketLabel: { types: ['string'], - default: i18n.translate('data.search.aggs.buckets.terms.missingBucketLabel', { - defaultMessage: 'Missing', - description: `Default label used in charts when documents are missing a field. - Visible when you create a chart with a terms aggregation and enable "Show missing values"`, - }), help: i18n.translate('data.search.aggs.buckets.terms.missingBucketLabel.help', { defaultMessage: 'Default label used in charts when documents are missing a field.', }), }, otherBucket: { types: ['boolean'], - default: false, help: i18n.translate('data.search.aggs.buckets.terms.otherBucket.help', { defaultMessage: 'When set to true, groups together any buckets beyond the allowed size', }), }, otherBucketLabel: { types: ['string'], - default: i18n.translate('data.search.aggs.buckets.terms.otherBucketLabel', { - defaultMessage: 'Other', - }), help: i18n.translate('data.search.aggs.buckets.terms.otherBucketLabel.help', { defaultMessage: 'Default label used in charts for documents in the Other bucket', }), @@ -148,32 +135,27 @@ export const aggTerms = (): FunctionDefinition => ({ defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', }), }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.terms.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; - let json; - try { - json = args.json ? JSON.parse(args.json) : undefined; - } catch (e) { - throw new Error('Unable to parse json argument string'); - } - - // Need to spread this object to work around TS bug: - // https://github.com/microsoft/TypeScript/issues/15300#issuecomment-436793742 - const orderAgg = args.orderAgg?.value ? { ...args.orderAgg.value } : undefined; - return { type: 'agg_type', value: { id, enabled, schema, - type: aggName, + type: BUCKET_TYPES.TERMS, params: { ...rest, - orderAgg, - json, + orderAgg: args.orderAgg?.value, + json: getParsedValue(args, 'json'), }, }, }; diff --git a/src/plugins/data/public/search/aggs/metrics/avg.ts b/src/plugins/data/public/search/aggs/metrics/avg.ts index d53ce8d3fc489..96be3e849a3e8 100644 --- a/src/plugins/data/public/search/aggs/metrics/avg.ts +++ b/src/plugins/data/public/search/aggs/metrics/avg.ts @@ -22,11 +22,16 @@ import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const averageTitle = i18n.translate('data.search.aggs.metrics.averageTitle', { defaultMessage: 'Average', }); +export interface AggParamsAvg extends BaseAggParams { + field: string; +} + export interface AvgMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/avg_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/avg_fn.test.ts new file mode 100644 index 0000000000000..0e2ee00df49dd --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/avg_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggAvg } from './avg_fn'; + +describe('agg_expression_functions', () => { + describe('aggAvg', () => { + const fn = functionWrapper(aggAvg()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "avg", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/avg_fn.ts b/src/plugins/data/public/search/aggs/metrics/avg_fn.ts new file mode 100644 index 0000000000000..c370623b2752a --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/avg_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggAvg'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggAvg = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.avg.help', { + defaultMessage: 'Generates a serialized agg config for a Avg agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.avg.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.avg.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.avg.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.avg.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.avg.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.avg.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.AVG, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_avg.ts b/src/plugins/data/public/search/aggs/metrics/bucket_avg.ts index 2c32ebc671539..ded17eebf465b 100644 --- a/src/plugins/data/public/search/aggs/metrics/bucket_avg.ts +++ b/src/plugins/data/public/search/aggs/metrics/bucket_avg.ts @@ -23,8 +23,14 @@ import { MetricAggType } from './metric_agg_type'; import { makeNestedLabel } from './lib/make_nested_label'; import { siblingPipelineAggHelper } from './lib/sibling_pipeline_agg_helper'; import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; import { GetInternalStartServicesFn } from '../../../types'; +export interface AggParamsBucketAvg extends BaseAggParams { + customMetric?: AggConfigSerialized; + customBucket?: AggConfigSerialized; +} + export interface BucketAvgMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_avg_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/bucket_avg_fn.test.ts new file mode 100644 index 0000000000000..7e08bc9954510 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/bucket_avg_fn.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggBucketAvg } from './bucket_avg_fn'; + +describe('agg_expression_functions', () => { + describe('aggBucketAvg', () => { + const fn = functionWrapper(aggBucketAvg()); + + test('handles customMetric and customBucket as a subexpression', () => { + const actual = fn({ + customMetric: fn({}), + customBucket: fn({}), + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "customBucket": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "avg_bucket", + }, + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "avg_bucket", + }, + "json": undefined, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_avg_fn.ts b/src/plugins/data/public/search/aggs/metrics/bucket_avg_fn.ts new file mode 100644 index 0000000000000..56643a2df54bd --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/bucket_avg_fn.ts @@ -0,0 +1,107 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggBucketAvg'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign< + AggArgs, + { customBucket?: AggExpressionType; customMetric?: AggExpressionType } +>; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggBucketAvg = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.bucket_avg.help', { + defaultMessage: 'Generates a serialized agg config for a Avg Bucket agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_avg.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.bucket_avg.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_avg.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + customBucket: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.bucket_avg.customBucket.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.bucket_avg.customMetric.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_avg.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_avg.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.AVG_BUCKET, + params: { + ...rest, + customBucket: args.customBucket?.value, + customMetric: args.customMetric?.value, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_max.ts b/src/plugins/data/public/search/aggs/metrics/bucket_max.ts index 1e57a2dd8e38e..dde328008b88a 100644 --- a/src/plugins/data/public/search/aggs/metrics/bucket_max.ts +++ b/src/plugins/data/public/search/aggs/metrics/bucket_max.ts @@ -22,8 +22,14 @@ import { MetricAggType } from './metric_agg_type'; import { makeNestedLabel } from './lib/make_nested_label'; import { siblingPipelineAggHelper } from './lib/sibling_pipeline_agg_helper'; import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; import { GetInternalStartServicesFn } from '../../../types'; +export interface AggParamsBucketMax extends BaseAggParams { + customMetric?: AggConfigSerialized; + customBucket?: AggConfigSerialized; +} + export interface BucketMaxMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_max_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/bucket_max_fn.test.ts new file mode 100644 index 0000000000000..b789bdf51ebd5 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/bucket_max_fn.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggBucketMax } from './bucket_max_fn'; + +describe('agg_expression_functions', () => { + describe('aggBucketMax', () => { + const fn = functionWrapper(aggBucketMax()); + + test('handles customMetric and customBucket as a subexpression', () => { + const actual = fn({ + customMetric: fn({}), + customBucket: fn({}), + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "customBucket": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "max_bucket", + }, + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "max_bucket", + }, + "json": undefined, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_max_fn.ts b/src/plugins/data/public/search/aggs/metrics/bucket_max_fn.ts new file mode 100644 index 0000000000000..896e9cf839605 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/bucket_max_fn.ts @@ -0,0 +1,107 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggBucketMax'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign< + AggArgs, + { customBucket?: AggExpressionType; customMetric?: AggExpressionType } +>; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggBucketMax = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.bucket_max.help', { + defaultMessage: 'Generates a serialized agg config for a Max Bucket agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_max.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.bucket_max.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_max.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + customBucket: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.bucket_max.customBucket.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.bucket_max.customMetric.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_max.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_max.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.MAX_BUCKET, + params: { + ...rest, + customBucket: args.customBucket?.value, + customMetric: args.customMetric?.value, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_min.ts b/src/plugins/data/public/search/aggs/metrics/bucket_min.ts index 0484af23a7141..9949524ce6110 100644 --- a/src/plugins/data/public/search/aggs/metrics/bucket_min.ts +++ b/src/plugins/data/public/search/aggs/metrics/bucket_min.ts @@ -22,8 +22,14 @@ import { MetricAggType } from './metric_agg_type'; import { makeNestedLabel } from './lib/make_nested_label'; import { siblingPipelineAggHelper } from './lib/sibling_pipeline_agg_helper'; import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; import { GetInternalStartServicesFn } from '../../../types'; +export interface AggParamsBucketMin extends BaseAggParams { + customMetric?: AggConfigSerialized; + customBucket?: AggConfigSerialized; +} + export interface BucketMinMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_min_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/bucket_min_fn.test.ts new file mode 100644 index 0000000000000..6ebc83417813b --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/bucket_min_fn.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggBucketMin } from './bucket_min_fn'; + +describe('agg_expression_functions', () => { + describe('aggBucketMin', () => { + const fn = functionWrapper(aggBucketMin()); + + test('handles customMetric and customBucket as a subexpression', () => { + const actual = fn({ + customMetric: fn({}), + customBucket: fn({}), + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "customBucket": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "min_bucket", + }, + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "min_bucket", + }, + "json": undefined, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_min_fn.ts b/src/plugins/data/public/search/aggs/metrics/bucket_min_fn.ts new file mode 100644 index 0000000000000..2ae3d9211227a --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/bucket_min_fn.ts @@ -0,0 +1,107 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggBucketMin'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign< + AggArgs, + { customBucket?: AggExpressionType; customMetric?: AggExpressionType } +>; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggBucketMin = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.bucket_min.help', { + defaultMessage: 'Generates a serialized agg config for a Min Bucket agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_min.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.bucket_min.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_min.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + customBucket: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.bucket_min.customBucket.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.bucket_min.customMetric.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_min.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_min.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.MIN_BUCKET, + params: { + ...rest, + customBucket: args.customBucket?.value, + customMetric: args.customMetric?.value, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_sum.ts b/src/plugins/data/public/search/aggs/metrics/bucket_sum.ts index 0a4d29a18a980..e69ae5798c6e1 100644 --- a/src/plugins/data/public/search/aggs/metrics/bucket_sum.ts +++ b/src/plugins/data/public/search/aggs/metrics/bucket_sum.ts @@ -22,8 +22,14 @@ import { MetricAggType } from './metric_agg_type'; import { makeNestedLabel } from './lib/make_nested_label'; import { siblingPipelineAggHelper } from './lib/sibling_pipeline_agg_helper'; import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; import { GetInternalStartServicesFn } from '../../../types'; +export interface AggParamsBucketSum extends BaseAggParams { + customMetric?: AggConfigSerialized; + customBucket?: AggConfigSerialized; +} + export interface BucketSumMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_sum_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/bucket_sum_fn.test.ts new file mode 100644 index 0000000000000..71549f41b1d15 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/bucket_sum_fn.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggBucketSum } from './bucket_sum_fn'; + +describe('agg_expression_functions', () => { + describe('aggBucketSum', () => { + const fn = functionWrapper(aggBucketSum()); + + test('handles customMetric and customBucket as a subexpression', () => { + const actual = fn({ + customMetric: fn({}), + customBucket: fn({}), + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "customBucket": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "sum_bucket", + }, + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "sum_bucket", + }, + "json": undefined, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_sum_fn.ts b/src/plugins/data/public/search/aggs/metrics/bucket_sum_fn.ts new file mode 100644 index 0000000000000..eceb11a90f293 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/bucket_sum_fn.ts @@ -0,0 +1,107 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggBucketSum'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign< + AggArgs, + { customBucket?: AggExpressionType; customMetric?: AggExpressionType } +>; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggBucketSum = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.bucket_sum.help', { + defaultMessage: 'Generates a serialized agg config for a Sum Bucket agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_sum.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.bucket_sum.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_sum.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + customBucket: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.bucket_sum.customBucket.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.bucket_sum.customMetric.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_sum.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.bucket_sum.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.SUM_BUCKET, + params: { + ...rest, + customBucket: args.customBucket?.value, + customMetric: args.customMetric?.value, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/cardinality.ts b/src/plugins/data/public/search/aggs/metrics/cardinality.ts index 10b6b5aff1abd..af594195fe027 100644 --- a/src/plugins/data/public/search/aggs/metrics/cardinality.ts +++ b/src/plugins/data/public/search/aggs/metrics/cardinality.ts @@ -22,11 +22,16 @@ import { MetricAggType, IMetricAggConfig } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const uniqueCountTitle = i18n.translate('data.search.aggs.metrics.uniqueCountTitle', { defaultMessage: 'Unique Count', }); +export interface AggParamsCardinality extends BaseAggParams { + field: string; +} + export interface CardinalityMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/cardinality_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/cardinality_fn.test.ts new file mode 100644 index 0000000000000..4008819018ee5 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/cardinality_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggCardinality } from './cardinality_fn'; + +describe('agg_expression_functions', () => { + describe('aggCardinality', () => { + const fn = functionWrapper(aggCardinality()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "cardinality", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/cardinality_fn.ts b/src/plugins/data/public/search/aggs/metrics/cardinality_fn.ts new file mode 100644 index 0000000000000..f30429993638f --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/cardinality_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggCardinality'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggCardinality = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.cardinality.help', { + defaultMessage: 'Generates a serialized agg config for a Cardinality agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cardinality.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.cardinality.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cardinality.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.cardinality.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cardinality.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cardinality.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.CARDINALITY, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/count_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/count_fn.test.ts new file mode 100644 index 0000000000000..846feb9296fca --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/count_fn.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggCount } from './count_fn'; + +describe('agg_expression_functions', () => { + describe('aggCount', () => { + const fn = functionWrapper(aggCount()); + + test('correctly creates agg type', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "count", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/count_fn.ts b/src/plugins/data/public/search/aggs/metrics/count_fn.ts new file mode 100644 index 0000000000000..f4c7e8e854230 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/count_fn.ts @@ -0,0 +1,88 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggCount'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggCount = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.count.help', { + defaultMessage: 'Generates a serialized agg config for a Count agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.count.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.count.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.count.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.count.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.count.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.COUNT, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/cumulative_sum.ts b/src/plugins/data/public/search/aggs/metrics/cumulative_sum.ts index 8ca922e144a1f..67e907239799a 100644 --- a/src/plugins/data/public/search/aggs/metrics/cumulative_sum.ts +++ b/src/plugins/data/public/search/aggs/metrics/cumulative_sum.ts @@ -22,8 +22,15 @@ import { MetricAggType } from './metric_agg_type'; import { parentPipelineAggHelper } from './lib/parent_pipeline_agg_helper'; import { makeNestedLabel } from './lib/make_nested_label'; import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; import { GetInternalStartServicesFn } from '../../../types'; +export interface AggParamsCumulativeSum extends BaseAggParams { + buckets_path: string; + customMetric?: AggConfigSerialized; + metricAgg?: string; +} + export interface CumulativeSumMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/cumulative_sum_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/cumulative_sum_fn.test.ts new file mode 100644 index 0000000000000..3cf53e3da153e --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/cumulative_sum_fn.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggCumulativeSum } from './cumulative_sum_fn'; + +describe('agg_expression_functions', () => { + describe('aggCumulativeSum', () => { + const fn = functionWrapper(aggCumulativeSum()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + buckets_path: 'the_sum', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": undefined, + }, + "schema": undefined, + "type": "cumulative_sum", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + buckets_path: 'the_sum', + metricAgg: 'sum', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": "sum", + }, + "schema": undefined, + "type": "cumulative_sum", + }, + } + `); + }); + + test('handles customMetric as a subexpression', () => { + const actual = fn({ + customMetric: fn({ buckets_path: 'the_sum' }), + buckets_path: 'the_sum', + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": undefined, + }, + "schema": undefined, + "type": "cumulative_sum", + }, + "json": undefined, + "metricAgg": undefined, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + buckets_path: 'the_sum', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + buckets_path: 'the_sum', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/cumulative_sum_fn.ts b/src/plugins/data/public/search/aggs/metrics/cumulative_sum_fn.ts new file mode 100644 index 0000000000000..950df03b10134 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/cumulative_sum_fn.ts @@ -0,0 +1,111 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggCumulativeSum'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggCumulativeSum = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.cumulative_sum.help', { + defaultMessage: 'Generates a serialized agg config for a Cumulative Sum agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cumulative_sum.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.cumulative_sum.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cumulative_sum.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + metricAgg: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cumulative_sum.metricAgg.help', { + defaultMessage: + 'Id for finding agg config to use for building parent pipeline aggregations', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.cumulative_sum.customMetric.help', { + defaultMessage: 'Agg config to use for building parent pipeline aggregations', + }), + }, + buckets_path: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.cumulative_sum.buckets_path.help', { + defaultMessage: 'Path to the metric of interest', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cumulative_sum.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.cumulative_sum.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.CUMULATIVE_SUM, + params: { + ...rest, + customMetric: args.customMetric?.value, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/derivative.ts b/src/plugins/data/public/search/aggs/metrics/derivative.ts index 5752a72c846aa..edb907ca4ed41 100644 --- a/src/plugins/data/public/search/aggs/metrics/derivative.ts +++ b/src/plugins/data/public/search/aggs/metrics/derivative.ts @@ -22,8 +22,15 @@ import { MetricAggType } from './metric_agg_type'; import { parentPipelineAggHelper } from './lib/parent_pipeline_agg_helper'; import { makeNestedLabel } from './lib/make_nested_label'; import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; import { GetInternalStartServicesFn } from '../../../types'; +export interface AggParamsDerivative extends BaseAggParams { + buckets_path: string; + customMetric?: AggConfigSerialized; + metricAgg?: string; +} + export interface DerivativeMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/derivative_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/derivative_fn.test.ts new file mode 100644 index 0000000000000..79ea7292104ee --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/derivative_fn.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggDerivative } from './derivative_fn'; + +describe('agg_expression_functions', () => { + describe('aggDerivative', () => { + const fn = functionWrapper(aggDerivative()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + buckets_path: 'the_sum', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": undefined, + }, + "schema": undefined, + "type": "derivative", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + buckets_path: 'the_sum', + metricAgg: 'sum', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": "sum", + }, + "schema": undefined, + "type": "derivative", + }, + } + `); + }); + + test('handles customMetric as a subexpression', () => { + const actual = fn({ + customMetric: fn({ buckets_path: 'the_sum' }), + buckets_path: 'the_sum', + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": undefined, + }, + "schema": undefined, + "type": "derivative", + }, + "json": undefined, + "metricAgg": undefined, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + buckets_path: 'the_sum', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + buckets_path: 'the_sum', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/derivative_fn.ts b/src/plugins/data/public/search/aggs/metrics/derivative_fn.ts new file mode 100644 index 0000000000000..90b88b4de2712 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/derivative_fn.ts @@ -0,0 +1,111 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggDerivative'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggDerivative = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.derivative.help', { + defaultMessage: 'Generates a serialized agg config for a Derivative agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.derivative.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.derivative.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.derivative.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + metricAgg: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.derivative.metricAgg.help', { + defaultMessage: + 'Id for finding agg config to use for building parent pipeline aggregations', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.derivative.customMetric.help', { + defaultMessage: 'Agg config to use for building parent pipeline aggregations', + }), + }, + buckets_path: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.derivative.buckets_path.help', { + defaultMessage: 'Path to the metric of interest', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.derivative.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.derivative.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.DERIVATIVE, + params: { + ...rest, + customMetric: args.customMetric?.value, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/geo_bounds.ts b/src/plugins/data/public/search/aggs/metrics/geo_bounds.ts index 00927ebba56bf..864e97ca8dfe7 100644 --- a/src/plugins/data/public/search/aggs/metrics/geo_bounds.ts +++ b/src/plugins/data/public/search/aggs/metrics/geo_bounds.ts @@ -22,6 +22,11 @@ import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; + +export interface AggParamsGeoBounds extends BaseAggParams { + field: string; +} export interface GeoBoundsMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; diff --git a/src/plugins/data/public/search/aggs/metrics/geo_bounds_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/geo_bounds_fn.test.ts new file mode 100644 index 0000000000000..96bd31916784a --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/geo_bounds_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggGeoBounds } from './geo_bounds_fn'; + +describe('agg_expression_functions', () => { + describe('aggGeoBounds', () => { + const fn = functionWrapper(aggGeoBounds()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "geo_bounds", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/geo_bounds_fn.ts b/src/plugins/data/public/search/aggs/metrics/geo_bounds_fn.ts new file mode 100644 index 0000000000000..8ba71a098fc70 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/geo_bounds_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggGeoBounds'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggGeoBounds = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.geo_bounds.help', { + defaultMessage: 'Generates a serialized agg config for a Geo Bounds agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.geo_bounds.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.geo_bounds.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.geo_bounds.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.geo_bounds.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.geo_bounds.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.geo_bounds.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.GEO_BOUNDS, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/geo_centroid.ts b/src/plugins/data/public/search/aggs/metrics/geo_centroid.ts index a4b084f794a5d..2bbb6b2de8d87 100644 --- a/src/plugins/data/public/search/aggs/metrics/geo_centroid.ts +++ b/src/plugins/data/public/search/aggs/metrics/geo_centroid.ts @@ -22,6 +22,11 @@ import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; + +export interface AggParamsGeoCentroid extends BaseAggParams { + field: string; +} export interface GeoCentroidMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; diff --git a/src/plugins/data/public/search/aggs/metrics/geo_centroid_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/geo_centroid_fn.test.ts new file mode 100644 index 0000000000000..bf9a4548bafbf --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/geo_centroid_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggGeoCentroid } from './geo_centroid_fn'; + +describe('agg_expression_functions', () => { + describe('aggGeoCentroid', () => { + const fn = functionWrapper(aggGeoCentroid()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "geo_centroid", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/geo_centroid_fn.ts b/src/plugins/data/public/search/aggs/metrics/geo_centroid_fn.ts new file mode 100644 index 0000000000000..464f9b535cd8b --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/geo_centroid_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggGeoCentroid'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggGeoCentroid = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.geo_centroid.help', { + defaultMessage: 'Generates a serialized agg config for a Geo Centroid agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.geo_centroid.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.geo_centroid.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.geo_centroid.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.geo_centroid.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.geo_centroid.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.geo_centroid.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.GEO_CENTROID, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/index.ts b/src/plugins/data/public/search/aggs/metrics/index.ts index eb93e99427f65..ef7de68b05de9 100644 --- a/src/plugins/data/public/search/aggs/metrics/index.ts +++ b/src/plugins/data/public/search/aggs/metrics/index.ts @@ -21,3 +21,23 @@ export * from './metric_agg_type'; export * from './metric_agg_types'; export * from './lib/parent_pipeline_agg_helper'; export * from './lib/sibling_pipeline_agg_helper'; +export { AggParamsAvg } from './avg'; +export { AggParamsCardinality } from './cardinality'; +export { AggParamsGeoBounds } from './geo_bounds'; +export { AggParamsGeoCentroid } from './geo_centroid'; +export { AggParamsMax } from './max'; +export { AggParamsMedian } from './median'; +export { AggParamsMin } from './min'; +export { AggParamsStdDeviation } from './std_deviation'; +export { AggParamsSum } from './sum'; +export { AggParamsBucketAvg } from './bucket_avg'; +export { AggParamsBucketMax } from './bucket_max'; +export { AggParamsBucketMin } from './bucket_min'; +export { AggParamsBucketSum } from './bucket_sum'; +export { AggParamsCumulativeSum } from './cumulative_sum'; +export { AggParamsDerivative } from './derivative'; +export { AggParamsMovingAvg } from './moving_avg'; +export { AggParamsPercentileRanks } from './percentile_ranks'; +export { AggParamsPercentiles } from './percentiles'; +export { AggParamsSerialDiff } from './serial_diff'; +export { AggParamsTopHit } from './top_hit'; diff --git a/src/plugins/data/public/search/aggs/metrics/max.ts b/src/plugins/data/public/search/aggs/metrics/max.ts index 88e8b485cb73f..49cbfba5a269d 100644 --- a/src/plugins/data/public/search/aggs/metrics/max.ts +++ b/src/plugins/data/public/search/aggs/metrics/max.ts @@ -22,11 +22,16 @@ import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const maxTitle = i18n.translate('data.search.aggs.metrics.maxTitle', { defaultMessage: 'Max', }); +export interface AggParamsMax extends BaseAggParams { + field: string; +} + export interface MaxMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/max_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/max_fn.test.ts new file mode 100644 index 0000000000000..156b51ca54af5 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/max_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggMax } from './max_fn'; + +describe('agg_expression_functions', () => { + describe('aggMax', () => { + const fn = functionWrapper(aggMax()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "max", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/max_fn.ts b/src/plugins/data/public/search/aggs/metrics/max_fn.ts new file mode 100644 index 0000000000000..1d68c8919fca8 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/max_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggMax'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggMax = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.max.help', { + defaultMessage: 'Generates a serialized agg config for a Max agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.max.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.max.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.max.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.max.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.max.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.max.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.MAX, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/median.ts b/src/plugins/data/public/search/aggs/metrics/median.ts index a398f017602b0..725fdcb2400d1 100644 --- a/src/plugins/data/public/search/aggs/metrics/median.ts +++ b/src/plugins/data/public/search/aggs/metrics/median.ts @@ -22,11 +22,16 @@ import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const medianTitle = i18n.translate('data.search.aggs.metrics.medianTitle', { defaultMessage: 'Median', }); +export interface AggParamsMedian extends BaseAggParams { + field: string; +} + export interface MedianMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/median_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/median_fn.test.ts new file mode 100644 index 0000000000000..69200c35426c8 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/median_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggMedian } from './median_fn'; + +describe('agg_expression_functions', () => { + describe('aggMedian', () => { + const fn = functionWrapper(aggMedian()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "median", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/median_fn.ts b/src/plugins/data/public/search/aggs/metrics/median_fn.ts new file mode 100644 index 0000000000000..2e8e89992136e --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/median_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggMedian'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggMedian = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.median.help', { + defaultMessage: 'Generates a serialized agg config for a Median agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.median.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.median.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.median.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.median.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.median.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.median.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.MEDIAN, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/min.ts b/src/plugins/data/public/search/aggs/metrics/min.ts index aae16f357186c..0f52aa8a4f788 100644 --- a/src/plugins/data/public/search/aggs/metrics/min.ts +++ b/src/plugins/data/public/search/aggs/metrics/min.ts @@ -22,11 +22,16 @@ import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const minTitle = i18n.translate('data.search.aggs.metrics.minTitle', { defaultMessage: 'Min', }); +export interface AggParamsMin extends BaseAggParams { + field: string; +} + export interface MinMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/min_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/min_fn.test.ts new file mode 100644 index 0000000000000..ef32d086e41f7 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/min_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggMin } from './min_fn'; + +describe('agg_expression_functions', () => { + describe('aggMin', () => { + const fn = functionWrapper(aggMin()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "min", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/min_fn.ts b/src/plugins/data/public/search/aggs/metrics/min_fn.ts new file mode 100644 index 0000000000000..b51da46a137b0 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/min_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggMin'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggMin = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.min.help', { + defaultMessage: 'Generates a serialized agg config for a Min agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.min.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.min.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.min.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.min.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.min.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.min.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.MIN, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/moving_avg.ts b/src/plugins/data/public/search/aggs/metrics/moving_avg.ts index 94b9b1d8cd487..38a824629d304 100644 --- a/src/plugins/data/public/search/aggs/metrics/moving_avg.ts +++ b/src/plugins/data/public/search/aggs/metrics/moving_avg.ts @@ -22,8 +22,17 @@ import { MetricAggType } from './metric_agg_type'; import { parentPipelineAggHelper } from './lib/parent_pipeline_agg_helper'; import { makeNestedLabel } from './lib/make_nested_label'; import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; import { GetInternalStartServicesFn } from '../../../types'; +export interface AggParamsMovingAvg extends BaseAggParams { + buckets_path: string; + window?: number; + script?: string; + customMetric?: AggConfigSerialized; + metricAgg?: string; +} + export interface MovingAvgMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/moving_avg_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/moving_avg_fn.test.ts new file mode 100644 index 0000000000000..d6c0e6b2cbd6e --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/moving_avg_fn.test.ts @@ -0,0 +1,130 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggMovingAvg } from './moving_avg_fn'; + +describe('agg_expression_functions', () => { + describe('aggMovingAvg', () => { + const fn = functionWrapper(aggMovingAvg()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + buckets_path: 'the_sum', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": undefined, + "script": undefined, + "window": undefined, + }, + "schema": undefined, + "type": "moving_avg", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + buckets_path: 'the_sum', + metricAgg: 'sum', + window: 10, + script: 'test', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": "sum", + "script": "test", + "window": 10, + }, + "schema": undefined, + "type": "moving_avg", + }, + } + `); + }); + + test('handles customMetric as a subexpression', () => { + const actual = fn({ + customMetric: fn({ buckets_path: 'the_sum' }), + buckets_path: 'the_sum', + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": undefined, + "script": undefined, + "window": undefined, + }, + "schema": undefined, + "type": "moving_avg", + }, + "json": undefined, + "metricAgg": undefined, + "script": undefined, + "window": undefined, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + buckets_path: 'the_sum', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + buckets_path: 'the_sum', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/moving_avg_fn.ts b/src/plugins/data/public/search/aggs/metrics/moving_avg_fn.ts new file mode 100644 index 0000000000000..54a3fa176385b --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/moving_avg_fn.ts @@ -0,0 +1,124 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggMovingAvg'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggMovingAvg = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.moving_avg.help', { + defaultMessage: 'Generates a serialized agg config for a Moving Average agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.moving_avg.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.moving_avg.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.moving_avg.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + metricAgg: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.moving_avg.metricAgg.help', { + defaultMessage: + 'Id for finding agg config to use for building parent pipeline aggregations', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.moving_avg.customMetric.help', { + defaultMessage: 'Agg config to use for building parent pipeline aggregations', + }), + }, + window: { + types: ['number'], + help: i18n.translate('data.search.aggs.metrics.moving_avg.window.help', { + defaultMessage: 'The size of window to "slide" across the histogram.', + }), + }, + buckets_path: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.derivative.buckets_path.help', { + defaultMessage: 'Path to the metric of interest', + }), + }, + script: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.moving_avg.script.help', { + defaultMessage: + 'Id for finding agg config to use for building parent pipeline aggregations', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.moving_avg.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.moving_avg.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.MOVING_FN, + params: { + ...rest, + customMetric: args.customMetric?.value, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts b/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts index 0d79665ff9c4e..c8383f6bcc3d9 100644 --- a/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts +++ b/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts @@ -24,6 +24,12 @@ import { getPercentileValue } from './percentiles_get_value'; import { METRIC_TYPES } from './metric_agg_types'; import { FIELD_FORMAT_IDS, KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; + +export interface AggParamsPercentileRanks extends BaseAggParams { + field: string; + values?: number[]; +} // required by the values editor export type IPercentileRanksAggConfig = IResponseAggConfig; diff --git a/src/plugins/data/public/search/aggs/metrics/percentile_ranks_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/percentile_ranks_fn.test.ts new file mode 100644 index 0000000000000..e3ce91bafd40a --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/percentile_ranks_fn.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggPercentileRanks } from './percentile_ranks_fn'; + +describe('agg_expression_functions', () => { + describe('aggPercentileRanks', () => { + const fn = functionWrapper(aggPercentileRanks()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + "values": undefined, + }, + "schema": undefined, + "type": "percentile_ranks", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + values: [1, 2, 3], + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + "values": Array [ + 1, + 2, + 3, + ], + }, + "schema": undefined, + "type": "percentile_ranks", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/percentile_ranks_fn.ts b/src/plugins/data/public/search/aggs/metrics/percentile_ranks_fn.ts new file mode 100644 index 0000000000000..851e938f28c1c --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/percentile_ranks_fn.ts @@ -0,0 +1,102 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggPercentileRanks'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggPercentileRanks = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.percentile_ranks.help', { + defaultMessage: 'Generates a serialized agg config for a Percentile Ranks agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.percentile_ranks.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.percentile_ranks.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.percentile_ranks.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.percentile_ranks.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + values: { + types: ['number'], + multi: true, + help: i18n.translate('data.search.aggs.metrics.percentile_ranks.values.help', { + defaultMessage: 'Range of percentiles ranks', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.percentile_ranks.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.percentile_ranks.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.PERCENTILE_RANKS, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/percentiles.ts b/src/plugins/data/public/search/aggs/metrics/percentiles.ts index 040a52588dd94..ad3c19cfaffcc 100644 --- a/src/plugins/data/public/search/aggs/metrics/percentiles.ts +++ b/src/plugins/data/public/search/aggs/metrics/percentiles.ts @@ -25,6 +25,12 @@ import { getResponseAggConfigClass, IResponseAggConfig } from './lib/get_respons import { getPercentileValue } from './percentiles_get_value'; import { ordinalSuffix } from './lib/ordinal_suffix'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; + +export interface AggParamsPercentiles extends BaseAggParams { + field: string; + percents?: number[]; +} export type IPercentileAggConfig = IResponseAggConfig; diff --git a/src/plugins/data/public/search/aggs/metrics/percentiles_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/percentiles_fn.test.ts new file mode 100644 index 0000000000000..2074cc1d89527 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/percentiles_fn.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggPercentiles } from './percentiles_fn'; + +describe('agg_expression_functions', () => { + describe('aggPercentiles', () => { + const fn = functionWrapper(aggPercentiles()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + "percents": undefined, + }, + "schema": undefined, + "type": "percentiles", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + percents: [1, 2, 3], + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + "percents": Array [ + 1, + 2, + 3, + ], + }, + "schema": undefined, + "type": "percentiles", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/percentiles_fn.ts b/src/plugins/data/public/search/aggs/metrics/percentiles_fn.ts new file mode 100644 index 0000000000000..b799be07925fa --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/percentiles_fn.ts @@ -0,0 +1,102 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggPercentiles'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggPercentiles = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.percentiles.help', { + defaultMessage: 'Generates a serialized agg config for a Percentiles agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.percentiles.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.percentiles.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.percentiles.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.percentiles.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + percents: { + types: ['number'], + multi: true, + help: i18n.translate('data.search.aggs.metrics.percentiles.percents.help', { + defaultMessage: 'Range of percentiles ranks', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.percentiles.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.percentiles.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.PERCENTILES, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/serial_diff.ts b/src/plugins/data/public/search/aggs/metrics/serial_diff.ts index 2b1498560f862..fe112a50ad3c1 100644 --- a/src/plugins/data/public/search/aggs/metrics/serial_diff.ts +++ b/src/plugins/data/public/search/aggs/metrics/serial_diff.ts @@ -22,8 +22,15 @@ import { MetricAggType } from './metric_agg_type'; import { parentPipelineAggHelper } from './lib/parent_pipeline_agg_helper'; import { makeNestedLabel } from './lib/make_nested_label'; import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; import { GetInternalStartServicesFn } from '../../../types'; +export interface AggParamsSerialDiff extends BaseAggParams { + buckets_path: string; + customMetric?: AggConfigSerialized; + metricAgg?: string; +} + export interface SerialDiffMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/serial_diff_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/serial_diff_fn.test.ts new file mode 100644 index 0000000000000..1bb859ad4bad8 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/serial_diff_fn.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggSerialDiff } from './serial_diff_fn'; + +describe('agg_expression_functions', () => { + describe('aggSerialDiff', () => { + const fn = functionWrapper(aggSerialDiff()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + buckets_path: 'the_sum', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": undefined, + }, + "schema": undefined, + "type": "serial_diff", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + buckets_path: 'the_sum', + metricAgg: 'sum', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": "sum", + }, + "schema": undefined, + "type": "serial_diff", + }, + } + `); + }); + + test('handles customMetric as a subexpression', () => { + const actual = fn({ + customMetric: fn({ buckets_path: 'the_sum' }), + buckets_path: 'the_sum', + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "buckets_path": "the_sum", + "customLabel": undefined, + "customMetric": undefined, + "json": undefined, + "metricAgg": undefined, + }, + "schema": undefined, + "type": "serial_diff", + }, + "json": undefined, + "metricAgg": undefined, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + buckets_path: 'the_sum', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + buckets_path: 'the_sum', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/serial_diff_fn.ts b/src/plugins/data/public/search/aggs/metrics/serial_diff_fn.ts new file mode 100644 index 0000000000000..9ba313aff7386 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/serial_diff_fn.ts @@ -0,0 +1,111 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggSerialDiff'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggSerialDiff = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.serial_diff.help', { + defaultMessage: 'Generates a serialized agg config for a Serial Differencing agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.serial_diff.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.serial_diff.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.serial_diff.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + metricAgg: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.serial_diff.metricAgg.help', { + defaultMessage: + 'Id for finding agg config to use for building parent pipeline aggregations', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.serial_diff.customMetric.help', { + defaultMessage: 'Agg config to use for building parent pipeline aggregations', + }), + }, + buckets_path: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.serial_diff.buckets_path.help', { + defaultMessage: 'Path to the metric of interest', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.serial_diff.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.serial_diff.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.SERIAL_DIFF, + params: { + ...rest, + customMetric: args.customMetric?.value, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/std_deviation.ts b/src/plugins/data/public/search/aggs/metrics/std_deviation.ts index e972132542ceb..1733d5476f667 100644 --- a/src/plugins/data/public/search/aggs/metrics/std_deviation.ts +++ b/src/plugins/data/public/search/aggs/metrics/std_deviation.ts @@ -24,6 +24,11 @@ import { METRIC_TYPES } from './metric_agg_types'; import { getResponseAggConfigClass, IResponseAggConfig } from './lib/get_response_agg_config_class'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; + +export interface AggParamsStdDeviation extends BaseAggParams { + field: string; +} interface ValProp { valProp: string[]; diff --git a/src/plugins/data/public/search/aggs/metrics/std_deviation_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/std_deviation_fn.test.ts new file mode 100644 index 0000000000000..bfa6aa7cc4122 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/std_deviation_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggStdDeviation } from './std_deviation_fn'; + +describe('agg_expression_functions', () => { + describe('aggStdDeviation', () => { + const fn = functionWrapper(aggStdDeviation()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "std_dev", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/std_deviation_fn.ts b/src/plugins/data/public/search/aggs/metrics/std_deviation_fn.ts new file mode 100644 index 0000000000000..70623e2e48041 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/std_deviation_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggStdDeviation'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggStdDeviation = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.std_deviation.help', { + defaultMessage: 'Generates a serialized agg config for a Standard Deviation agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.std_deviation.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.std_deviation.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.std_deviation.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.std_deviation.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.std_deviation.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.std_deviation.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.STD_DEV, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/sum.ts b/src/plugins/data/public/search/aggs/metrics/sum.ts index 545c6d6a4939e..70fc379f2d5f1 100644 --- a/src/plugins/data/public/search/aggs/metrics/sum.ts +++ b/src/plugins/data/public/search/aggs/metrics/sum.ts @@ -22,11 +22,16 @@ import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const sumTitle = i18n.translate('data.search.aggs.metrics.sumTitle', { defaultMessage: 'Sum', }); +export interface AggParamsSum extends BaseAggParams { + field: string; +} + export interface SumMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } diff --git a/src/plugins/data/public/search/aggs/metrics/sum_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/sum_fn.test.ts new file mode 100644 index 0000000000000..6e57632ba84cc --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/sum_fn.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggSum } from './sum_fn'; + +describe('agg_expression_functions', () => { + describe('aggSum', () => { + const fn = functionWrapper(aggSum()); + + test('required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + }, + "schema": undefined, + "type": "sum", + }, + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/sum_fn.ts b/src/plugins/data/public/search/aggs/metrics/sum_fn.ts new file mode 100644 index 0000000000000..a277aef02693f --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/sum_fn.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggSum'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggSum = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.sum.help', { + defaultMessage: 'Generates a serialized agg config for a Sum agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.sum.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.sum.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.sum.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.sum.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.sum.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.sum.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.SUM, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/metrics/top_hit.ts b/src/plugins/data/public/search/aggs/metrics/top_hit.ts index 15da2b485aee7..df7a76f151c07 100644 --- a/src/plugins/data/public/search/aggs/metrics/top_hit.ts +++ b/src/plugins/data/public/search/aggs/metrics/top_hit.ts @@ -23,6 +23,15 @@ import { IMetricAggConfig, MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; + +export interface AggParamsTopHit extends BaseAggParams { + field: string; + aggregate: 'min' | 'max' | 'sum' | 'average' | 'concat'; + sortField?: string; + size?: number; + sortOrder?: 'desc' | 'asc'; +} export interface TopHitMetricAggDependencies { getInternalStartServices: GetInternalStartServicesFn; diff --git a/src/plugins/data/public/search/aggs/metrics/top_hit_fn.test.ts b/src/plugins/data/public/search/aggs/metrics/top_hit_fn.test.ts new file mode 100644 index 0000000000000..d0e9788f85025 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/top_hit_fn.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggTopHit } from './top_hit_fn'; + +describe('agg_expression_functions', () => { + describe('aggTopHit', () => { + const fn = functionWrapper(aggTopHit()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + aggregate: 'min', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "aggregate": "min", + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + "size": undefined, + "sortField": undefined, + "sortOrder": undefined, + }, + "schema": undefined, + "type": "top_hits", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + id: '1', + enabled: false, + schema: 'whatever', + field: 'machine.os.keyword', + sortOrder: 'asc', + size: 6, + aggregate: 'min', + sortField: '_score', + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": false, + "id": "1", + "params": Object { + "aggregate": "min", + "customLabel": undefined, + "field": "machine.os.keyword", + "json": undefined, + "size": 6, + "sortField": "_score", + "sortOrder": "asc", + }, + "schema": "whatever", + "type": "top_hits", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + aggregate: 'min', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + aggregate: 'min', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/metrics/top_hit_fn.ts b/src/plugins/data/public/search/aggs/metrics/top_hit_fn.ts new file mode 100644 index 0000000000000..adfd22b540e06 --- /dev/null +++ b/src/plugins/data/public/search/aggs/metrics/top_hit_fn.ts @@ -0,0 +1,122 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggTopHit'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggTopHit = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.metrics.top_hit.help', { + defaultMessage: 'Generates a serialized agg config for a Top Hit agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.top_hit.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.top_hit.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.top_hit.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.metrics.top_hit.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + aggregate: { + types: ['string'], + required: true, + options: ['min', 'max', 'sum', 'average', 'concat'], + help: i18n.translate('data.search.aggs.metrics.top_hit.aggregate.help', { + defaultMessage: 'Aggregate type', + }), + }, + size: { + types: ['number'], + help: i18n.translate('data.search.aggs.metrics.top_hit.size.help', { + defaultMessage: 'Max number of buckets to retrieve', + }), + }, + sortOrder: { + types: ['string'], + options: ['desc', 'asc'], + help: i18n.translate('data.search.aggs.metrics.top_hit.sortOrder.help', { + defaultMessage: 'Order in which to return the results: asc or desc', + }), + }, + sortField: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.top_hit.sortField.help', { + defaultMessage: 'Field to order results by', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.top_hit.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.top_hit.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.TOP_HITS, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/types.ts b/src/plugins/data/public/search/aggs/types.ts index 1c5b5b458ce90..a784bfaada4c7 100644 --- a/src/plugins/data/public/search/aggs/types.ts +++ b/src/plugins/data/public/search/aggs/types.ts @@ -21,11 +21,43 @@ import { IndexPattern } from '../../index_patterns'; import { AggConfigSerialized, AggConfigs, + AggParamsRange, + AggParamsIpRange, + AggParamsDateRange, + AggParamsFilter, + AggParamsFilters, + AggParamsSignificantTerms, + AggParamsGeoTile, + AggParamsGeoHash, AggParamsTerms, + AggParamsAvg, + AggParamsCardinality, + AggParamsGeoBounds, + AggParamsGeoCentroid, + AggParamsMax, + AggParamsMedian, + AggParamsMin, + AggParamsStdDeviation, + AggParamsSum, + AggParamsBucketAvg, + AggParamsBucketMax, + AggParamsBucketMin, + AggParamsBucketSum, + AggParamsCumulativeSum, + AggParamsDerivative, + AggParamsMovingAvg, + AggParamsPercentileRanks, + AggParamsPercentiles, + AggParamsSerialDiff, + AggParamsTopHit, + AggParamsHistogram, + AggParamsDateHistogram, AggTypesRegistrySetup, AggTypesRegistryStart, CreateAggConfigParams, getCalculateAutoTimeExpression, + METRIC_TYPES, + BUCKET_TYPES, } from './'; export { IAggConfig, AggConfigSerialized } from './agg_config'; @@ -55,6 +87,12 @@ export interface SearchAggsStart { types: AggTypesRegistryStart; } +/** @internal */ +export interface BaseAggParams { + json?: string; + customLabel?: string; +} + /** @internal */ export interface AggExpressionType { type: 'agg_type'; @@ -74,5 +112,36 @@ export type AggExpressionFunctionArgs< * @internal */ export interface AggParamsMapping { - terms: AggParamsTerms; + [BUCKET_TYPES.RANGE]: AggParamsRange; + [BUCKET_TYPES.IP_RANGE]: AggParamsIpRange; + [BUCKET_TYPES.DATE_RANGE]: AggParamsDateRange; + [BUCKET_TYPES.FILTER]: AggParamsFilter; + [BUCKET_TYPES.FILTERS]: AggParamsFilters; + [BUCKET_TYPES.SIGNIFICANT_TERMS]: AggParamsSignificantTerms; + [BUCKET_TYPES.GEOTILE_GRID]: AggParamsGeoTile; + [BUCKET_TYPES.GEOHASH_GRID]: AggParamsGeoHash; + [BUCKET_TYPES.HISTOGRAM]: AggParamsHistogram; + [BUCKET_TYPES.DATE_HISTOGRAM]: AggParamsDateHistogram; + [BUCKET_TYPES.TERMS]: AggParamsTerms; + [METRIC_TYPES.AVG]: AggParamsAvg; + [METRIC_TYPES.CARDINALITY]: AggParamsCardinality; + [METRIC_TYPES.COUNT]: BaseAggParams; + [METRIC_TYPES.GEO_BOUNDS]: AggParamsGeoBounds; + [METRIC_TYPES.GEO_CENTROID]: AggParamsGeoCentroid; + [METRIC_TYPES.MAX]: AggParamsMax; + [METRIC_TYPES.MEDIAN]: AggParamsMedian; + [METRIC_TYPES.MIN]: AggParamsMin; + [METRIC_TYPES.STD_DEV]: AggParamsStdDeviation; + [METRIC_TYPES.SUM]: AggParamsSum; + [METRIC_TYPES.AVG_BUCKET]: AggParamsBucketAvg; + [METRIC_TYPES.MAX_BUCKET]: AggParamsBucketMax; + [METRIC_TYPES.MIN_BUCKET]: AggParamsBucketMin; + [METRIC_TYPES.SUM_BUCKET]: AggParamsBucketSum; + [METRIC_TYPES.CUMULATIVE_SUM]: AggParamsCumulativeSum; + [METRIC_TYPES.DERIVATIVE]: AggParamsDerivative; + [METRIC_TYPES.MOVING_FN]: AggParamsMovingAvg; + [METRIC_TYPES.PERCENTILE_RANKS]: AggParamsPercentileRanks; + [METRIC_TYPES.PERCENTILES]: AggParamsPercentiles; + [METRIC_TYPES.SERIAL_DIFF]: AggParamsSerialDiff; + [METRIC_TYPES.TOP_HITS]: AggParamsTopHit; } diff --git a/src/legacy/core_plugins/interpreter/public/registries.ts b/src/plugins/data/public/search/aggs/utils/get_parsed_value.ts similarity index 59% rename from src/legacy/core_plugins/interpreter/public/registries.ts rename to src/plugins/data/public/search/aggs/utils/get_parsed_value.ts index 63fd9089acf4a..48e752369d1d3 100644 --- a/src/legacy/core_plugins/interpreter/public/registries.ts +++ b/src/plugins/data/public/search/aggs/utils/get_parsed_value.ts @@ -17,13 +17,18 @@ * under the License. */ -import { npSetup } from 'ui/new_platform'; - -export const functionsRegistry = npSetup.plugins.expressions.__LEGACY.functions; -export const renderersRegistry = npSetup.plugins.expressions.__LEGACY.renderers; -export const typesRegistry = npSetup.plugins.expressions.__LEGACY.types; -export const registries = { - browserFunctions: functionsRegistry, - renderers: renderersRegistry, - types: typesRegistry, +/** + * This method parses a JSON string and constructs the Object or object described by the string. + * If the given string is not valid JSON, you will get a syntax error. + * @param data { Object } - an object that contains the required for parsing field + * @param key { string} - name of the field to be parsed + * + * @internal + */ +export const getParsedValue = (data: any, key: string) => { + try { + return data[key] ? JSON.parse(data[key]) : undefined; + } catch (e) { + throw new Error(`Unable to parse ${key} argument string`); + } }; diff --git a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_description.tsx b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_description.tsx index d440f09ca09dd..3606bfbaeb1f9 100644 --- a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_description.tsx +++ b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_description.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { EuiCodeBlock, EuiDescriptionList, EuiSpacer } from '@elastic/eui'; import { ShardFailure } from './shard_failure_types'; -import { getFlattenedObject } from '../../../../../core/utils'; +import { getFlattenedObject } from '../../../../../core/public'; import { ShardFailureDescriptionHeader } from './shard_failure_description_header'; /** diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index e61ad2a6eefed..84c6eea7c4ff1 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -22,6 +22,7 @@ import './index.scss'; import { PluginInitializerContext } from 'src/core/public'; import { EmbeddablePublicPlugin } from './plugin'; +export { EMBEDDABLE_ORIGINATING_APP_PARAM } from './types'; export { ACTION_ADD_PANEL, ACTION_APPLY_FILTER, diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx index fc5438b8c8dcb..196bd593eb8d5 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx @@ -22,10 +22,12 @@ import { Embeddable, EmbeddableInput } from '../embeddables'; import { ViewMode } from '../types'; import { ContactCardEmbeddable } from '../test_samples'; import { embeddablePluginMock } from '../../mocks'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; const { doStart } = embeddablePluginMock.createInstance(); const start = doStart(); const getFactory = start.getEmbeddableFactory; +const applicationMock = applicationServiceMock.createStartContract(); class EditableEmbeddable extends Embeddable { public readonly type = 'EDITABLE_EMBEDDABLE'; @@ -41,7 +43,7 @@ class EditableEmbeddable extends Embeddable { } test('is compatible when edit url is available, in edit mode and editable', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true), @@ -50,7 +52,7 @@ test('is compatible when edit url is available, in edit mode and editable', asyn }); test('getHref returns the edit urls', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); expect(action.getHref).toBeDefined(); if (action.getHref) { @@ -64,7 +66,7 @@ test('getHref returns the edit urls', async () => { }); test('is not compatible when edit url is not available', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); const embeddable = new ContactCardEmbeddable( { id: '123', @@ -83,7 +85,7 @@ test('is not compatible when edit url is not available', async () => { }); test('is not visible when edit url is available but in view mode', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( @@ -98,7 +100,7 @@ test('is not visible when edit url is available but in view mode', async () => { }); test('is not compatible when edit url is available, in edit mode, but not editable', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index d57867900c24b..d1edddb2aa86b 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -20,10 +20,11 @@ import { i18n } from '@kbn/i18n'; import { ApplicationStart } from 'kibana/public'; import { Action } from 'src/plugins/ui_actions/public'; +import { take } from 'rxjs/operators'; import { ViewMode } from '../types'; import { EmbeddableFactoryNotFoundError } from '../errors'; -import { IEmbeddable } from '../embeddables'; import { EmbeddableStart } from '../../plugin'; +import { EMBEDDABLE_ORIGINATING_APP_PARAM, IEmbeddable } from '../..'; export const ACTION_EDIT_PANEL = 'editPanel'; @@ -35,11 +36,18 @@ export class EditPanelAction implements Action { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; public order = 50; + public currentAppId: string | undefined; constructor( private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], private readonly application: ApplicationStart - ) {} + ) { + if (this.application?.currentAppId$) { + this.application.currentAppId$ + .pipe(take(1)) + .subscribe((appId: string | undefined) => (this.currentAppId = appId)); + } + } public getDisplayName({ embeddable }: ActionContext) { const factory = this.getEmbeddableFactory(embeddable.type); @@ -93,7 +101,15 @@ export class EditPanelAction implements Action { } public async getHref({ embeddable }: ActionContext): Promise { - const editUrl = embeddable ? embeddable.getOutput().editUrl : undefined; + let editUrl = embeddable ? embeddable.getOutput().editUrl : undefined; + if (editUrl && this.currentAppId) { + editUrl += `?${EMBEDDABLE_ORIGINATING_APP_PARAM}=${this.currentAppId}`; + + // TODO: Remove this after https://github.com/elastic/kibana/pull/63443 + if (this.currentAppId === 'kibana') { + editUrl += `:${window.location.hash.split(/[\/\?]/)[1]}`; + } + } return editUrl ? editUrl : ''; } } diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 9dd4c74c624d9..384297d8dee7d 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -44,6 +44,7 @@ import { import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; import { embeddablePluginMock } from '../../mocks'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; const actionRegistry = new Map(); const triggerRegistry = new Map(); @@ -55,6 +56,7 @@ const trigger: Trigger = { id: CONTEXT_MENU_TRIGGER, }; const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); +const applicationMock = applicationServiceMock.createStartContract(); actionRegistry.set(editModeAction.id, editModeAction); triggerRegistry.set(trigger.id, trigger); @@ -159,7 +161,7 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => { getAllEmbeddableFactories={start.getEmbeddableFactories} getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} - application={{} as any} + application={applicationMock} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} @@ -199,7 +201,7 @@ const renderInEditModeAndOpenContextMenu = async ( getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} - application={{} as any} + application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} /> @@ -306,7 +308,7 @@ test('HelloWorldContainer in edit mode shows edit mode actions', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} - application={{} as any} + application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} /> @@ -369,7 +371,7 @@ test('Updates when hidePanelTitles is toggled', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} - application={{} as any} + application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} /> @@ -422,7 +424,7 @@ test('Check when hide header option is false', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} - application={{} as any} + application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} hideHeader={false} diff --git a/src/plugins/embeddable/public/types.ts b/src/plugins/embeddable/public/types.ts index 2d112b2359818..a57af862f2a34 100644 --- a/src/plugins/embeddable/public/types.ts +++ b/src/plugins/embeddable/public/types.ts @@ -26,6 +26,8 @@ import { EmbeddableFactoryDefinition, } from './lib/embeddables'; +export const EMBEDDABLE_ORIGINATING_APP_PARAM = 'embeddableOriginatingApp'; + export type EmbeddableFactoryRegistry = Map; export type EmbeddableFactoryProvider = < diff --git a/src/plugins/kibana_legacy/public/angular/angular_config.tsx b/src/plugins/kibana_legacy/public/angular/angular_config.tsx index 295cf27688c80..a52546d68c4d8 100644 --- a/src/plugins/kibana_legacy/public/angular/angular_config.tsx +++ b/src/plugins/kibana_legacy/public/angular/angular_config.tsx @@ -36,7 +36,7 @@ import { ChromeBreadcrumb, EnvironmentMode, PackageInfo } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart, LegacyCoreStart } from 'kibana/public'; -import { modifyUrl } from '../../../../core/utils'; +import { modifyUrl } from '../../../../core/public'; import { toMountPoint } from '../../../kibana_react/public'; import { isSystemApiRequest, UrlOverflowService } from '../utils'; import { formatAngularHttpError, isAngularHttpError } from '../notify/lib'; diff --git a/src/plugins/kibana_legacy/public/notify/app_redirect/app_redirect.ts b/src/plugins/kibana_legacy/public/notify/app_redirect/app_redirect.ts index e79ab4b2fbc6d..01321c60f5c87 100644 --- a/src/plugins/kibana_legacy/public/notify/app_redirect/app_redirect.ts +++ b/src/plugins/kibana_legacy/public/notify/app_redirect/app_redirect.ts @@ -18,7 +18,7 @@ */ import { ILocationService } from 'angular'; -import { modifyUrl } from '../../../../../core/utils'; +import { modifyUrl } from '../../../../../core/public'; import { ToastsStart } from '../../../../../core/public'; const APP_REDIRECT_MESSAGE_PARAM = 'app_redirect_message'; diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index 9e0a7c40c043f..e38a0ef9830ea 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -19,7 +19,14 @@ import { SavedObjectsPublicPlugin } from './plugin'; -export { OnSaveProps, SavedObjectSaveModal, SaveResult, showSaveModal } from './save_modal'; +export { + OnSaveProps, + SavedObjectSaveModal, + SavedObjectSaveModalOrigin, + SaveModalState, + SaveResult, + showSaveModal, +} from './save_modal'; export { getSavedObjectFinder, SavedObjectFinderUi, SavedObjectMetaData } from './finder'; export { SavedObjectLoader, diff --git a/src/plugins/saved_objects/public/save_modal/index.ts b/src/plugins/saved_objects/public/save_modal/index.ts index f26aa732f30a1..7c32337bb314a 100644 --- a/src/plugins/saved_objects/public/save_modal/index.ts +++ b/src/plugins/saved_objects/public/save_modal/index.ts @@ -17,5 +17,6 @@ * under the License. */ -export { SavedObjectSaveModal, OnSaveProps } from './saved_object_save_modal'; +export { SavedObjectSaveModal, OnSaveProps, SaveModalState } from './saved_object_save_modal'; +export { SavedObjectSaveModalOrigin } from './saved_object_save_modal_origin'; export { showSaveModal, SaveResult } from './show_saved_object_save_modal'; diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 95eb56c0e874b..962f993633e6f 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -53,14 +53,15 @@ interface Props { onClose: () => void; title: string; showCopyOnSave: boolean; + initialCopyOnSave?: boolean; objectType: string; confirmButtonLabel?: React.ReactNode; - options?: React.ReactNode; + options?: React.ReactNode | ((state: SaveModalState) => React.ReactNode); description?: string; showDescription: boolean; } -interface State { +export interface SaveModalState { title: string; copyOnSave: boolean; isTitleDuplicateConfirmed: boolean; @@ -71,11 +72,11 @@ interface State { const generateId = htmlIdGenerator(); -export class SavedObjectSaveModal extends React.Component { +export class SavedObjectSaveModal extends React.Component { private warning = React.createRef(); public readonly state = { title: this.props.title, - copyOnSave: false, + copyOnSave: Boolean(this.props.initialCopyOnSave), isTitleDuplicateConfirmed: false, hasTitleDuplicate: false, isLoading: false, @@ -139,7 +140,9 @@ export class SavedObjectSaveModal extends React.Component { {this.renderViewDescription()} - {this.props.options} + {typeof this.props.options === 'function' + ? this.props.options(this.state) + : this.props.options} diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx new file mode 100644 index 0000000000000..34f4bc593fdc4 --- /dev/null +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx @@ -0,0 +1,117 @@ +/* + * 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 React, { Fragment, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { OnSaveProps, SaveModalState, SavedObjectSaveModal } from '.'; + +interface SaveModalDocumentInfo { + id?: string; + title: string; + description?: string; +} + +interface OriginSaveModalProps { + originatingApp?: string; + documentInfo: SaveModalDocumentInfo; + objectType: string; + onClose: () => void; + onSave: (props: OnSaveProps & { returnToOrigin: boolean }) => void; +} + +export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { + const [returnToOriginMode, setReturnToOriginMode] = useState(Boolean(props.originatingApp)); + const { documentInfo } = props; + + const returnLabel = i18n.translate('savedObjects.saveModalOrigin.returnToOriginLabel', { + defaultMessage: 'Return', + }); + const addLabel = i18n.translate('savedObjects.saveModalOrigin.addToOriginLabel', { + defaultMessage: 'Add', + }); + + const getReturnToOriginSwitch = (state: SaveModalState) => { + if (!props.originatingApp) { + return; + } + let origin = props.originatingApp!; + + // TODO: Remove this after https://github.com/elastic/kibana/pull/63443 + if (origin.startsWith('kibana:')) { + origin = origin.split(':')[1]; + } + + if ( + !state.copyOnSave || + origin === 'dashboard' // dashboard supports adding a copied panel on save... + ) { + const originVerb = !documentInfo.id || state.copyOnSave ? addLabel : returnLabel; + return ( + + + { + setReturnToOriginMode(event.target.checked); + }} + label={ + + } + /> + + + ); + } else { + setReturnToOriginMode(false); + } + }; + + const onModalSave = (onSaveProps: OnSaveProps) => { + props.onSave({ ...onSaveProps, returnToOrigin: returnToOriginMode }); + }; + + const confirmButtonLabel = returnToOriginMode + ? i18n.translate('savedObjects.saveModalOrigin.saveAndReturnLabel', { + defaultMessage: 'Save and return', + }) + : null; + + return ( + + ); +} diff --git a/src/plugins/share/server/routes/goto.ts b/src/plugins/share/server/routes/goto.ts index 747af3b9e57df..0dbddd6552c36 100644 --- a/src/plugins/share/server/routes/goto.ts +++ b/src/plugins/share/server/routes/goto.ts @@ -23,7 +23,7 @@ import { schema } from '@kbn/config-schema'; import { shortUrlAssertValid } from './lib/short_url_assert_valid'; import { ShortUrlLookupService } from './lib/short_url_lookup'; import { getGotoPath } from '../../common/short_url_routes'; -import { modifyUrl } from '../../../../core/utils'; +import { modifyUrl } from '../../../../core/server'; export const createGotoRoute = ({ router, diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 8c0117e5a7266..c392d8ce64205 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -489,4 +489,4 @@ exports[`TelemetryManagementSectionComponent renders null because query does not /> `; -exports[`TelemetryManagementSectionComponent test the wrapper (for coverage purposes) 1`] = `""`; +exports[`TelemetryManagementSectionComponent test the wrapper (for coverage purposes) 1`] = `null`; diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx index d0c2bd13f802d..c13f639f31447 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx @@ -18,10 +18,9 @@ */ import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { TelemetryManagementSection } from './telemetry_management_section'; +import TelemetryManagementSection from './telemetry_management_section'; import { TelemetryService } from '../../../telemetry/public/services'; import { coreMock } from '../../../../core/public/mocks'; -import { telemetryManagementSectionWrapper } from './telemetry_management_section_wrapper'; describe('TelemetryManagementSectionComponent', () => { const coreStart = coreMock.createStart(); @@ -270,10 +269,12 @@ describe('TelemetryManagementSectionComponent', () => { notifications: coreStart.notifications, http: coreSetup.http, }); - const Wrapper = telemetryManagementSectionWrapper(telemetryService); + expect( shallowWithIntl( - { }); }; } + +// required for lazy loading +// eslint-disable-next-line import/no-default-export +export default TelemetryManagementSection; diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx index b8b20b68f666e..f61268c4772a3 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx @@ -17,23 +17,27 @@ * under the License. */ -import React from 'react'; +import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; -import { TelemetryManagementSection } from './telemetry_management_section'; // It should be this but the types are way too vague in the AdvancedSettings plugin `Record` // type Props = Omit; type Props = any; +const TelemetryManagementSectionComponent = lazy(() => import('./telemetry_management_section')); + export function telemetryManagementSectionWrapper( telemetryService: TelemetryPluginSetup['telemetryService'] ) { const TelemetryManagementSectionWrapper = (props: Props) => ( - + }> + + ); return TelemetryManagementSectionWrapper; diff --git a/src/plugins/telemetry_management_section/public/plugin.ts b/src/plugins/telemetry_management_section/public/plugin.tsx similarity index 100% rename from src/plugins/telemetry_management_section/public/plugin.ts rename to src/plugins/telemetry_management_section/public/plugin.tsx diff --git a/src/plugins/vis_type_timelion/server/plugin.ts b/src/plugins/vis_type_timelion/server/plugin.ts index 40e89008e7562..435ec9027eef2 100644 --- a/src/plugins/vis_type_timelion/server/plugin.ts +++ b/src/plugins/vis_type_timelion/server/plugin.ts @@ -25,7 +25,7 @@ import { PluginInitializerContext, RecursiveReadonly, } from '../../../../src/core/server'; -import { deepFreeze } from '../../../../src/core/utils'; +import { deepFreeze } from '../../../../src/core/server'; import { configSchema } from '../config'; import loadFunctions from './lib/load_functions'; import { functionsRoute } from './routes/functions'; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js index 9fdb8ccc919b7..62d8cf3297132 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js @@ -74,6 +74,8 @@ export class VisEditor extends Component { handleUiState = (field, value) => { this.props.vis.uiState.set(field, value); + // reload visualization because data might need to be re-fetched + this.props.vis.uiState.emit('reload'); }; updateVisState = debounce(() => { diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 874fc037c4896..dc0c4310de576 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -87,7 +87,7 @@ export const TimeSeries = ({ const tooltipFormatter = decorateFormatter(xAxisFormatter); const uiSettings = getUISettings(); const timeZone = getTimezone(uiSettings); - const hasBarChart = series.some(({ bars }) => bars.show); + const hasBarChart = series.some(({ bars }) => bars?.show); // compute the theme based on the bg color const theme = getTheme(darkMode, backgroundColor); @@ -180,7 +180,7 @@ export const TimeSeries = ({ // Only use color mapping if there is no color from the server const finalColor = color ?? colors.mappedColors.mapping[label]; - if (bars.show) { + if (bars?.show) { return ( results => { const metric = getLastMetric(series); - if (metric.type === 'std_deviation' && metric.mode === 'band') { - getSplits(resp, panel, series, meta).forEach(split => { - const upper = split.timeseries.buckets.map( - mapBucket(_.assign({}, metric, { mode: 'upper' })) - ); - const lower = split.timeseries.buckets.map( - mapBucket(_.assign({}, metric, { mode: 'lower' })) - ); - results.push({ - id: `${split.id}:upper`, - label: split.label, - color: split.color, - lines: { show: true, fill: 0.5, lineWidth: 0 }, - points: { show: false }, - fillBetween: `${split.id}:lower`, - data: upper, - }); + if (metric.type === METRIC_TYPES.STD_DEVIATION && metric.mode === 'band') { + getSplits(resp, panel, series, meta).forEach(({ id, color, label, timeseries }) => { + const data = timeseries.buckets.map(bucket => [ + bucket.key, + getAggValue(bucket, { ...metric, mode: 'upper' }), + getAggValue(bucket, { ...metric, mode: 'lower' }), + ]); + results.push({ - id: `${split.id}:lower`, - color: split.color, - lines: { show: true, fill: false, lineWidth: 0 }, + id, + label, + color, + data, + lines: { + show: series.chart_type === 'line', + fill: 0.5, + lineWidth: 0, + mode: 'band', + }, + bars: { + show: series.chart_type === 'bar', + fill: 0.5, + mode: 'band', + }, points: { show: false }, - data: lower, }); }); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.test.js index 77949ff94dc4c..a229646ba8f3f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.test.js @@ -86,29 +86,18 @@ describe('stdDeviationBands(resp, panel, series)', () => { test('creates a series', () => { const next = results => results; const results = stdDeviationBands(resp, panel, series)(next)([]); - expect(results).toHaveLength(2); + expect(results).toHaveLength(1); expect(results[0]).toEqual({ - id: 'test:upper', + id: 'test', label: 'Std. Deviation of cpu', color: 'rgb(255, 0, 0)', - lines: { show: true, fill: 0.5, lineWidth: 0 }, - points: { show: false }, - fillBetween: 'test:lower', - data: [ - [1, 3.2], - [2, 3.5], - ], - }); - - expect(results[1]).toEqual({ - id: 'test:lower', - color: 'rgb(255, 0, 0)', - lines: { show: true, fill: false, lineWidth: 0 }, + lines: { show: true, fill: 0.5, lineWidth: 0, mode: 'band' }, + bars: { show: false, fill: 0.5, mode: 'band' }, points: { show: false }, data: [ - [1, 0.2], - [2, 0.5], + [1, 3.2, 0.2], + [2, 3.5, 0.5], ], }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.js index 96ead42c55253..1c6ee94050a62 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.js @@ -17,40 +17,36 @@ * under the License. */ -import _ from 'lodash'; -import { getSplits } from '../../helpers/get_splits'; -import { getLastMetric } from '../../helpers/get_last_metric'; -import { getSiblingAggValue } from '../../helpers/get_sibling_agg_value'; +import { getSplits, getLastMetric, getSiblingAggValue } from '../../helpers'; export function stdDeviationSibling(resp, panel, series, meta) { return next => results => { const metric = getLastMetric(series); if (metric.mode === 'band' && metric.type === 'std_deviation_bucket') { getSplits(resp, panel, series, meta).forEach(split => { - const mapBucketByMode = mode => { - return bucket => { - return [bucket.key, getSiblingAggValue(split, _.assign({}, metric, { mode }))]; - }; - }; + const data = split.timeseries.buckets.map(bucket => [ + bucket.key, + getSiblingAggValue(split, { ...metric, mode: 'upper' }), + getSiblingAggValue(split, { ...metric, mode: 'lower' }), + ]); - const upperData = split.timeseries.buckets.map(mapBucketByMode('upper')); - const lowerData = split.timeseries.buckets.map(mapBucketByMode('lower')); - - results.push({ - id: `${split.id}:lower`, - lines: { show: true, fill: false, lineWidth: 0 }, - points: { show: false }, - color: split.color, - data: lowerData, - }); results.push({ - id: `${split.id}:upper`, + id: split.id, label: split.label, color: split.color, - lines: { show: true, fill: 0.5, lineWidth: 0 }, + lines: { + show: series.chart_type === 'line', + fill: 0.5, + lineWidth: 0, + mode: 'band', + }, + bars: { + show: series.chart_type === 'bar', + fill: 0.5, + mode: 'band', + }, points: { show: false }, - fillBetween: `${split.id}:lower`, - data: upperData, + data, }); }); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.test.js index adc5a3a4a991b..b93d929d5157a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.test.js @@ -86,29 +86,18 @@ describe('stdDeviationSibling(resp, panel, series)', () => { test('creates a series', () => { const next = results => results; const results = stdDeviationSibling(resp, panel, series)(next)([]); - expect(results).toHaveLength(2); + expect(results).toHaveLength(1); expect(results[0]).toEqual({ - id: 'test:lower', + id: 'test', color: 'rgb(255, 0, 0)', - lines: { show: true, fill: false, lineWidth: 0 }, - points: { show: false }, - data: [ - [1, 0.01], - [2, 0.01], - ], - }); - - expect(results[1]).toEqual({ - id: 'test:upper', label: 'Overall Std. Deviation of Average of cpu', - color: 'rgb(255, 0, 0)', - fillBetween: 'test:lower', - lines: { show: true, fill: 0.5, lineWidth: 0 }, + lines: { show: true, fill: 0.5, lineWidth: 0, mode: 'band' }, + bars: { show: false, fill: 0.5, mode: 'band' }, points: { show: false }, data: [ - [1, 0.7], - [2, 0.7], + [1, 0.7, 0.01], + [2, 0.7, 0.01], ], }); }); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 71b31b7f74168..89697ecd7ed71 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -60,6 +60,7 @@ export interface VisualizeInput extends EmbeddableInput { vis?: { colors?: { [key: string]: string }; }; + table?: unknown; } export interface VisualizeOutput extends EmbeddableOutput { @@ -77,7 +78,7 @@ export class VisualizeEmbeddable extends Embeddable; private subscriptions: Subscription[] = []; private expression: string = ''; private vis: Vis; @@ -108,6 +109,7 @@ export class VisualizeEmbeddable extends Embeddable { - this.vis.uiState.set(key, visCustomizations[key]); - }); + if (visCustomizations.vis) { + this.vis.uiState.set('vis', visCustomizations.vis); + getKeys(visCustomizations).forEach(key => { + this.vis.uiState.set(key, visCustomizations[key]); + }); + } + if (visCustomizations.table) { + this.vis.uiState.set('table', visCustomizations.table); + } this.vis.uiState.on('change', this.uiStateChangeHandler); } } else if (this.parent) { @@ -307,6 +314,7 @@ export class VisualizeEmbeddable extends Embeddable s.unsubscribe()); this.vis.uiState.off('change', this.uiStateChangeHandler); + this.vis.uiState.off('reload', this.reload); if (this.handler) { this.handler.destroy(); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 6ab1c98645988..c6d43a4ef2f80 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -19,12 +19,14 @@ import { i18n } from '@kbn/i18n'; import { SavedObjectMetaData } from 'src/plugins/saved_objects/public'; +import { first } from 'rxjs/operators'; import { SavedObjectAttributes } from '../../../../core/public'; import { EmbeddableFactoryDefinition, EmbeddableOutput, ErrorEmbeddable, IContainer, + EMBEDDABLE_ORIGINATING_APP_PARAM, } from '../../../embeddable/public'; import { DisabledLabEmbeddable } from './disabled_lab_embeddable'; import { VisualizeEmbeddable, VisualizeInput, VisualizeOutput } from './visualize_embeddable'; @@ -59,6 +61,7 @@ export class VisualizeEmbeddableFactory VisualizationAttributes > { public readonly type = VISUALIZE_EMBEDDABLE_TYPE; + public readonly savedObjectMetaData: SavedObjectMetaData = { name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }), includeFields: ['visState'], @@ -98,6 +101,18 @@ export class VisualizeEmbeddableFactory }); } + public async getCurrentAppId() { + let currentAppId = await this.deps + .start() + .core.application.currentAppId$.pipe(first()) + .toPromise(); + // TODO: Remove this after https://github.com/elastic/kibana/pull/63443 + if (currentAppId === 'kibana') { + currentAppId += `:${window.location.hash.split(/[\/\?]/)[1]}`; + } + return currentAppId; + } + public async createFromSavedObject( savedObjectId: string, input: Partial & { id: string }, @@ -118,8 +133,9 @@ export class VisualizeEmbeddableFactory public async create() { // TODO: This is a bit of a hack to preserve the original functionality. Ideally we will clean this up // to allow for in place creation of visualizations without having to navigate away to a new URL. + const originatingAppParam = await this.getCurrentAppId(); showNewVisModal({ - editorParams: ['addToDashboard'], + editorParams: [`${EMBEDDABLE_ORIGINATING_APP_PARAM}=${originatingAppParam}`], }); return undefined; } diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index d6eeffdb01459..70c3bc2c1ed05 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -20,7 +20,7 @@ import { PluginInitializerContext } from '../../../core/public'; import { VisualizationsSetup, VisualizationsStart } from './'; import { VisualizationsPlugin } from './plugin'; -import { coreMock } from '../../../core/public/mocks'; +import { coreMock, applicationServiceMock } from '../../../core/public/mocks'; import { embeddablePluginMock } from '../../../plugins/embeddable/public/mocks'; import { expressionsPluginMock } from '../../../plugins/expressions/public/mocks'; import { dataPluginMock } from '../../../plugins/data/public/mocks'; @@ -65,6 +65,7 @@ const createInstance = async () => { expressions: expressionsPluginMock.createStartContract(), inspector: inspectorPluginMock.createStartContract(), uiActions: uiActionsPluginMock.createStartContract(), + application: applicationServiceMock.createStartContract(), }); return { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index b3e8c9b5b61b3..29d66ea963a66 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -17,7 +17,13 @@ * under the License. */ -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + ApplicationStart, +} from '../../../core/public'; import { TypesService, TypesSetup, TypesStart } from './vis_types'; import { setUISettings, @@ -95,6 +101,7 @@ export interface VisualizationsStartDeps { expressions: ExpressionsStart; inspector: InspectorStart; uiActions: UiActionsStart; + application: ApplicationStart; } /** @@ -131,7 +138,6 @@ export class VisualizationsPlugin expressions.registerRenderer(visualizationRenderer); expressions.registerFunction(rangeExpressionFunction); expressions.registerFunction(visDimensionExpressionFunction); - const embeddableFactory = new VisualizeEmbeddableFactory({ start }); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx index 5637aeafc6f14..2fdbdedd5b590 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx @@ -144,7 +144,7 @@ describe('NewVisModal', () => { isOpen={true} onClose={onClose} visTypesRegistry={visTypes} - editorParams={['foo=true', 'bar=42', 'addToDashboard']} + editorParams={['foo=true', 'bar=42', 'embeddableOriginatingApp=notAnApp']} addBasePath={addBasePath} uiSettings={uiSettings} savedObjects={{} as SavedObjectsStart} @@ -152,7 +152,9 @@ describe('NewVisModal', () => { ); const visButton = wrapper.find('button[data-test-subj="visType-visWithAliasUrl"]'); visButton.simulate('click'); - expect(window.location.assign).toBeCalledWith('testbasepath/aliasUrl?addToDashboard'); + expect(window.location.assign).toBeCalledWith( + 'testbasepath/aliasUrl?embeddableOriginatingApp=notAnApp' + ); expect(onClose).toHaveBeenCalled(); }); diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index 448077819bb8d..6fd65da7e88d2 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -28,6 +28,7 @@ import { SearchSelection } from './search_selection'; import { TypeSelection } from './type_selection'; import { TypesStart, VisType, VisTypeAlias } from '../vis_types'; import { UsageCollectionSetup } from '../../../../plugins/usage_collection/public'; +import { EMBEDDABLE_ORIGINATING_APP_PARAM } from '../../../embeddable/public'; interface TypeSelectionProps { isOpen: boolean; @@ -143,8 +144,11 @@ class NewVisModal extends React.Component + param.startsWith(EMBEDDABLE_ORIGINATING_APP_PARAM) + ); + params = originatingAppParam ? `${params}?${originatingAppParam}` : params; } this.props.onClose(); window.location.assign(params); diff --git a/src/plugins/visualize/public/application/editor/editor.html b/src/plugins/visualize/public/application/editor/editor.html index a031d70ef9a83..3c3455fb34f18 100644 --- a/src/plugins/visualize/public/application/editor/editor.html +++ b/src/plugins/visualize/public/application/editor/editor.html @@ -76,7 +76,7 @@ filters="filters" query="query" app-state="appState" - /> + >

+ > diff --git a/src/plugins/visualize/public/application/editor/editor.js b/src/plugins/visualize/public/application/editor/editor.js index ef359dc0cc115..bd699c762371c 100644 --- a/src/plugins/visualize/public/application/editor/editor.js +++ b/src/plugins/visualize/public/application/editor/editor.js @@ -25,11 +25,12 @@ import { i18n } from '@kbn/i18n'; import { EventEmitter } from 'events'; import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; import { makeStateful, useVisualizeAppState, addEmbeddableToDashboardUrl } from './lib'; import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from '../breadcrumbs'; +import { EMBEDDABLE_ORIGINATING_APP_PARAM } from '../../../../embeddable/public'; + import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; import { unhashUrl, removeQueryParam } from '../../../../kibana_utils/public'; import { MarkdownSimple, toMountPoint } from '../../../../kibana_react/public'; @@ -38,9 +39,8 @@ import { subscribeWithScope, migrateLegacyQuery, } from '../../../../kibana_legacy/public'; -import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public'; +import { showSaveModal, SavedObjectSaveModalOrigin } from '../../../../saved_objects/public'; import { esFilters, connectToQueryState, syncQueryStateWithUrl } from '../../../../data/public'; -import { DashboardConstants } from '../../../../dashboard/public'; import { initVisEditorDirective } from './visualization_editor'; import { initVisualizationDirective } from './visualization'; @@ -110,6 +110,11 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), }; + const originatingApp = $route.current.params[EMBEDDABLE_ORIGINATING_APP_PARAM]; + removeQueryParam(history, EMBEDDABLE_ORIGINATING_APP_PARAM); + + $scope.getOriginatingApp = () => originatingApp; + const visStateToEditorState = () => { const savedVisState = visualizations.convertFromSerializedVis(vis.serialize()); return { @@ -144,13 +149,58 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.embeddableHandler = embeddableHandler; $scope.topNavMenu = [ + ...($scope.getOriginatingApp() && savedVis.id + ? [ + { + id: 'saveAndReturn', + label: i18n.translate('visualize.topNavMenu.saveAndReturnVisualizationButtonLabel', { + defaultMessage: 'Save and return', + }), + emphasize: true, + iconType: 'check', + description: i18n.translate( + 'visualize.topNavMenu.saveAndReturnVisualizationButtonAriaLabel', + { + defaultMessage: 'Finish editing visualization and return to the last app', + } + ), + testId: 'visualizesaveAndReturnButton', + disableButton() { + return Boolean($scope.dirty); + }, + tooltip() { + if ($scope.dirty) { + return i18n.translate( + 'visualize.topNavMenu.saveAndReturnVisualizationDisabledButtonTooltip', + { + defaultMessage: 'Apply or Discard your changes before finishing', + } + ); + } + }, + run: async () => { + const saveOptions = { + confirmOverwrite: false, + returnToOrigin: true, + }; + return doSave(saveOptions); + }, + }, + ] + : []), ...(visualizeCapabilities.save ? [ { id: 'save', - label: i18n.translate('visualize.topNavMenu.saveVisualizationButtonLabel', { - defaultMessage: 'save', - }), + label: + savedVis.id && $scope.getOriginatingApp() + ? i18n.translate('visualize.topNavMenu.saveVisualizationAsButtonLabel', { + defaultMessage: 'save as', + }) + : i18n.translate('visualize.topNavMenu.saveVisualizationButtonLabel', { + defaultMessage: 'save', + }), + emphasize: !savedVis.id || !$scope.getOriginatingApp(), description: i18n.translate('visualize.topNavMenu.saveVisualizationButtonAriaLabel', { defaultMessage: 'Save Visualization', }), @@ -175,6 +225,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState isTitleDuplicateConfirmed, onTitleDuplicate, newDescription, + returnToOrigin, }) => { const currentTitle = savedVis.title; savedVis.title = newTitle; @@ -184,6 +235,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState confirmOverwrite: false, isTitleDuplicateConfirmed, onTitleDuplicate, + returnToOrigin, }; return doSave(saveOptions).then(response => { // If the save wasn't successful, put the original values back. @@ -194,23 +246,13 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }); }; - const confirmButtonLabel = $scope.isAddToDashMode() ? ( - - ) : null; - const saveModal = ( - {}} - title={savedVis.title} - showCopyOnSave={savedVis.id ? true : false} - objectType="visualization" - confirmButtonLabel={confirmButtonLabel} - description={savedVis.description} - showDescription={true} + originatingApp={$scope.getOriginatingApp()} /> ); showSaveModal(saveModal, I18nContext); @@ -260,10 +302,13 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }, run() { const inspectorSession = embeddableHandler.openInspector(); - // Close the inspector if this scope is destroyed (e.g. because the user navigates away). - const removeWatch = $scope.$on('$destroy', () => inspectorSession.close()); - // Remove that watch in case the user closes the inspector session herself. - inspectorSession.onClose.finally(removeWatch); + + if (inspectorSession) { + // Close the inspector if this scope is destroyed (e.g. because the user navigates away). + const removeWatch = $scope.$on('$destroy', () => inspectorSession.close()); + // Remove that watch in case the user closes the inspector session herself. + inspectorSession.onClose.finally(removeWatch); + } }, tooltip() { if (!embeddableHandler.hasInspector || !embeddableHandler.hasInspector()) { @@ -387,6 +432,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState stateContainer ); vis.uiState = persistedState; + vis.uiState.on('reload', embeddableHandler.reload); $scope.uiState = persistedState; $scope.savedVis = savedVis; $scope.query = initialState.query; @@ -394,12 +440,6 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.refreshInterval = timefilter.getRefreshInterval(); handleLinkedSearch(initialState.linked); - const addToDashMode = - $route.current.params[DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM]; - removeQueryParam(history, DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM); - - $scope.isAddToDashMode = () => addToDashMode; - $scope.showFilterBar = () => { return vis.type.options.showFilterBar; }; @@ -534,6 +574,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.eventEmitter.off('apply', _applyVis); unsubscribePersisted(); + vis.uiState.off('reload', embeddableHandler.reload); unsubscribeStateUpdates(); stopAllSyncing(); @@ -599,6 +640,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState */ function doSave(saveOptions) { // vis.title was not bound and it's needed to reflect title into visState + const firstSave = !Boolean(savedVis.id); stateContainer.transitions.setVis({ title: savedVis.title, type: savedVis.type || stateContainer.getState().vis.type, @@ -626,15 +668,23 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState 'data-test-subj': 'saveVisualizationSuccess', }); - if ($scope.isAddToDashMode()) { + if ($scope.getOriginatingApp() && saveOptions.returnToOrigin) { const appPath = `${VisualizeConstants.EDIT_PATH}/${encodeURIComponent(savedVis.id)}`; + // Manually insert a new url so the back button will open the saved visualization. history.replace(appPath); setActiveUrl(appPath); - - const lastDashboardUrl = chrome.navLinks.get('kibana:dashboard').url; - const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardUrl, savedVis.id); - history.push(dashboardUrl); + const lastAppType = $scope.getOriginatingApp(); + let href = chrome.navLinks.get(lastAppType).url; + + // TODO: Remove this and use application.redirectTo after https://github.com/elastic/kibana/pull/63443 + if (lastAppType === 'kibana:dashboard') { + const savedVisId = firstSave || savedVis.copyOnSave ? savedVis.id : ''; + href = addEmbeddableToDashboardUrl(href, savedVisId); + history.push(href); + } else { + window.location.href = href; + } } else if (savedVis.id === $route.current.params.id) { chrome.docTitle.change(savedVis.lastSavedTitle); chrome.setBreadcrumbs($injector.invoke(getEditBreadcrumbs)); diff --git a/src/plugins/visualize/public/application/editor/lib/url_helper.ts b/src/plugins/visualize/public/application/editor/lib/url_helper.ts index 84e1ef9687cd0..9f8a0075118ae 100644 --- a/src/plugins/visualize/public/application/editor/lib/url_helper.ts +++ b/src/plugins/visualize/public/application/editor/lib/url_helper.ts @@ -33,8 +33,10 @@ export function addEmbeddableToDashboardUrl(dashboardUrl: string, embeddableId: const { url, query } = parseUrl(dashboardUrl); const [, dashboardId] = url.split(DashboardConstants.CREATE_NEW_DASHBOARD_URL); - query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = VISUALIZE_EMBEDDABLE_TYPE; - query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + if (embeddableId) { + query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = VISUALIZE_EMBEDDABLE_TYPE; + query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + } return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}${dashboardId}?${stringify(query)}`; } diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 410acdcb5680d..8180051f56e44 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -48,7 +48,8 @@ export default function({ getService, getPageObjects }) { await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess( - 'visualization from top nav add new panel' + 'visualization from top nav add new panel', + { redirectToOrigin: true } ); await retry.try(async () => { const panelCount = await PageObjects.dashboard.getPanelCount(); @@ -64,7 +65,8 @@ export default function({ getService, getPageObjects }) { await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess( - 'visualization from add new link' + 'visualization from add new link', + { redirectToOrigin: true } ); await retry.try(async () => { diff --git a/test/functional/apps/dashboard/edit_embeddable_redirects.js b/test/functional/apps/dashboard/edit_embeddable_redirects.js new file mode 100644 index 0000000000000..b45dcc2cedf9b --- /dev/null +++ b/test/functional/apps/dashboard/edit_embeddable_redirects.js @@ -0,0 +1,79 @@ +/* + * 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 expect from '@kbn/expect'; + +export default function({ getService, getPageObjects }) { + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + describe('edit embeddable redirects', () => { + before(async () => { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.switchToEditMode(); + }); + + it('redirects via save and return button after edit', async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.visualize.saveVisualizationAndReturn(); + }); + + it('redirects via save as button after edit, renaming itself', async () => { + const newTitle = 'wowee, looks like I have a new title'; + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.visualize.saveVisualizationExpectSuccess(newTitle, { + saveAsNew: false, + redirectToOrigin: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + const newPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(originalPanelCount); + const titles = await PageObjects.dashboard.getPanelTitles(); + expect(titles.indexOf(newTitle)).to.not.be(-1); + }); + + it('redirects via save as button after edit, adding a new panel', async () => { + const newTitle = 'wowee, my title just got cooler'; + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.visualize.saveVisualizationExpectSuccess(newTitle, { + saveAsNew: true, + redirectToOrigin: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + const newPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(originalPanelCount + 1); + const titles = await PageObjects.dashboard.getPanelTitles(); + expect(titles.indexOf(newTitle)).to.not.be(-1); + }); + }); +} diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index bd8e6812147e1..3b81a4d974bec 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -51,6 +51,7 @@ export default function({ getService, loadTestFile }) { loadTestFile(require.resolve('./empty_dashboard')); loadTestFile(require.resolve('./embeddable_rendering')); loadTestFile(require.resolve('./create_and_add_embeddables')); + loadTestFile(require.resolve('./edit_embeddable_redirects')); loadTestFile(require.resolve('./time_zones')); loadTestFile(require.resolve('./dashboard_options')); loadTestFile(require.resolve('./data_shared_attributes')); diff --git a/test/functional/apps/dashboard/view_edit.js b/test/functional/apps/dashboard/view_edit.js index a0b972f3ab63c..c8eb10d43ea83 100644 --- a/test/functional/apps/dashboard/view_edit.js +++ b/test/functional/apps/dashboard/view_edit.js @@ -136,7 +136,10 @@ export default function({ getService, getPageObjects }) { await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); - await PageObjects.visualize.saveVisualizationExpectSuccess('new viz panel'); + await PageObjects.visualize.saveVisualizationExpectSuccess('new viz panel', { + saveAsNew: false, + redirectToOrigin: true, + }); await PageObjects.dashboard.clickCancelOutOfEditMode(); // for this sleep see https://github.com/elastic/kibana/issues/22299 diff --git a/test/functional/apps/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.js index dcd185eba00e6..20e69ef8345c6 100644 --- a/test/functional/apps/discover/_discover_histogram.js +++ b/test/functional/apps/discover/_discover_histogram.js @@ -56,7 +56,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.selectIndexPattern('long-window-logstash-*'); // NOTE: For some reason without setting this relative time, the abs times will not fetch data. - await PageObjects.timePicker.setCommonlyUsedTime('superDatePickerCommonlyUsed_Last_1 year'); + await PageObjects.timePicker.setCommonlyUsedTime('Last_1 year'); }); after(async () => { await esArchiver.unload('long_window_logstash'); diff --git a/test/functional/apps/visualize/_point_series_options.js b/test/functional/apps/visualize/_point_series_options.js index d0f7810b6f8bb..17e0d1ca87fdd 100644 --- a/test/functional/apps/visualize/_point_series_options.js +++ b/test/functional/apps/visualize/_point_series_options.js @@ -27,12 +27,10 @@ export default function({ getService, getPageObjects }) { const PageObjects = getPageObjects([ 'visualize', 'header', - 'pointSeries', 'timePicker', 'visEditor', 'visChart', ]); - const pointSeriesVis = PageObjects.pointSeries; const inspector = getService('inspector'); async function initChart() { @@ -60,11 +58,11 @@ export default function({ getService, getPageObjects }) { await PageObjects.visEditor.clickMetricsAndAxes(); // add another value axis log.debug('adding axis'); - await pointSeriesVis.clickAddAxis(); + await PageObjects.visEditor.clickAddAxis(); // set average count to use second value axis await PageObjects.visEditor.toggleAccordion('visEditorSeriesAccordion3'); log.debug('Average memory value axis - ValueAxis-2'); - await pointSeriesVis.setSeriesAxis(1, 'ValueAxis-2'); + await PageObjects.visEditor.setSeriesAxis(1, 'ValueAxis-2'); await PageObjects.visChart.waitForVisualizationRenderingStabilized(); await PageObjects.visEditor.clickGo(); } @@ -151,16 +149,16 @@ export default function({ getService, getPageObjects }) { }); it('should put secondary axis on the right', async function() { - const length = await pointSeriesVis.getRightValueAxes(); + const length = await PageObjects.visChart.getRightValueAxes(); expect(length).to.be(1); }); }); describe('multiple chart types', function() { it('should change average series type to histogram', async function() { - await pointSeriesVis.setSeriesType(1, 'histogram'); + await PageObjects.visEditor.setSeriesType(1, 'histogram'); await PageObjects.visEditor.clickGo(); - const length = await pointSeriesVis.getHistogramSeries(); + const length = await PageObjects.visChart.getHistogramSeries(); expect(length).to.be(1); }); }); @@ -171,9 +169,9 @@ export default function({ getService, getPageObjects }) { }); it('should show category grid lines', async function() { - await pointSeriesVis.toggleGridCategoryLines(); + await PageObjects.visEditor.toggleGridCategoryLines(); await PageObjects.visEditor.clickGo(); - const gridLines = await pointSeriesVis.getGridLines(); + const gridLines = await PageObjects.visChart.getGridLines(); expect(gridLines.length).to.be(9); gridLines.forEach(gridLine => { expect(gridLine.y).to.be(0); @@ -181,10 +179,10 @@ export default function({ getService, getPageObjects }) { }); it('should show value axis grid lines', async function() { - await pointSeriesVis.setGridValueAxis('ValueAxis-2'); - await pointSeriesVis.toggleGridCategoryLines(); + await PageObjects.visEditor.setGridValueAxis('ValueAxis-2'); + await PageObjects.visEditor.toggleGridCategoryLines(); await PageObjects.visEditor.clickGo(); - const gridLines = await pointSeriesVis.getGridLines(); + const gridLines = await PageObjects.visChart.getGridLines(); expect(gridLines.length).to.be(9); gridLines.forEach(gridLine => { expect(gridLine.x).to.be(0); @@ -212,7 +210,7 @@ export default function({ getService, getPageObjects }) { }); it('should render a custom axis title when one is set, overriding the custom label', async function() { - await pointSeriesVis.setAxisTitle(axisTitle); + await PageObjects.visEditor.setAxisTitle(axisTitle); await PageObjects.visEditor.clickGo(); const title = await PageObjects.visChart.getYAxisTitle(); expect(title).to.be(axisTitle); diff --git a/test/functional/page_objects/console_page.js b/test/functional/page_objects/console_page.ts similarity index 65% rename from test/functional/page_objects/console_page.js rename to test/functional/page_objects/console_page.ts index 33d13d3064333..d8eb692a25044 100644 --- a/test/functional/page_objects/console_page.js +++ b/test/functional/page_objects/console_page.ts @@ -17,46 +17,47 @@ * under the License. */ -import Bluebird from 'bluebird'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { WebElementWrapper } from '../services/lib/web_element_wrapper'; -export function ConsolePageProvider({ getService }) { +export function ConsolePageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); - async function getVisibleTextFromAceEditor(editor) { - const lines = await editor.findAllByClassName('ace_line_group'); - const linesText = await Bluebird.map(lines, l => l.getVisibleText()); - return linesText.join('\n'); - } + class ConsolePage { + public async getVisibleTextFromAceEditor(editor: WebElementWrapper) { + const lines = await editor.findAllByClassName('ace_line_group'); + const linesText = await Promise.all(lines.map(async line => await line.getVisibleText())); + return linesText.join('\n'); + } - return new (class ConsolePage { - async getRequestEditor() { + public async getRequestEditor() { return await testSubjects.find('request-editor'); } - async getRequest() { + public async getRequest() { const requestEditor = await this.getRequestEditor(); - return await getVisibleTextFromAceEditor(requestEditor); + return await this.getVisibleTextFromAceEditor(requestEditor); } - async getResponse() { + public async getResponse() { const responseEditor = await testSubjects.find('response-editor'); - return await getVisibleTextFromAceEditor(responseEditor); + return await this.getVisibleTextFromAceEditor(responseEditor); } - async clickPlay() { + public async clickPlay() { await testSubjects.click('sendRequestButton'); } - async collapseHelp() { + public async collapseHelp() { await testSubjects.click('help-close-button'); } - async openSettings() { + public async openSettings() { await testSubjects.click('consoleSettingsButton'); } - async setFontSizeSetting(newSize) { + public async setFontSizeSetting(newSize: number) { await this.openSettings(); // while the settings form opens/loads this may fail, so retry for a while @@ -70,13 +71,15 @@ export function ConsolePageProvider({ getService }) { await testSubjects.click('settings-save-button'); } - async getFontSize(editor) { + public async getFontSize(editor: WebElementWrapper) { const aceLine = await editor.findByClassName('ace_line'); return await aceLine.getComputedStyle('font-size'); } - async getRequestFontSize() { + public async getRequestFontSize() { return await this.getFontSize(await this.getRequestEditor()); } - })(); + } + + return new ConsolePage(); } diff --git a/test/functional/page_objects/context_page.js b/test/functional/page_objects/context_page.ts similarity index 86% rename from test/functional/page_objects/context_page.js rename to test/functional/page_objects/context_page.ts index 6ab082bf65292..9cbada532cde3 100644 --- a/test/functional/page_objects/context_page.js +++ b/test/functional/page_objects/context_page.ts @@ -18,14 +18,15 @@ */ import rison from 'rison-node'; - +import { FtrProviderContext } from '../ftr_provider_context'; +// @ts-ignore not TS yet import getUrl from '../../../src/test_utils/get_url'; const DEFAULT_INITIAL_STATE = { columns: ['@message'], }; -export function ContextPageProvider({ getService, getPageObjects }) { +export function ContextPageProvider({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const config = getService('config'); const retry = getService('retry'); @@ -34,7 +35,7 @@ export function ContextPageProvider({ getService, getPageObjects }) { const log = getService('log'); class ContextPage { - async navigateTo(indexPattern, anchorId, overrideInitialState = {}) { + public async navigateTo(indexPattern: string, anchorId: string, overrideInitialState = {}) { const initialState = rison.encode({ ...DEFAULT_INITIAL_STATE, ...overrideInitialState, @@ -53,23 +54,23 @@ export function ContextPageProvider({ getService, getPageObjects }) { await PageObjects.common.sleep(1000); } - async getPredecessorCountPicker() { + public async getPredecessorCountPicker() { return await testSubjects.find('predecessorsCountPicker'); } - async getSuccessorCountPicker() { + public async getSuccessorCountPicker() { return await testSubjects.find('successorsCountPicker'); } - async getPredecessorLoadMoreButton() { + public async getPredecessorLoadMoreButton() { return await testSubjects.find('predecessorsLoadMoreButton'); } - async getSuccessorLoadMoreButton() { + public async getSuccessorLoadMoreButton() { return await testSubjects.find('successorsLoadMoreButton'); } - async clickPredecessorLoadMoreButton() { + public async clickPredecessorLoadMoreButton() { log.debug('Click Predecessor Load More Button'); await retry.try(async () => { const predecessorButton = await this.getPredecessorLoadMoreButton(); @@ -79,7 +80,7 @@ export function ContextPageProvider({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); } - async clickSuccessorLoadMoreButton() { + public async clickSuccessorLoadMoreButton() { log.debug('Click Successor Load More Button'); await retry.try(async () => { const sucessorButton = await this.getSuccessorLoadMoreButton(); @@ -89,7 +90,7 @@ export function ContextPageProvider({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); } - async waitUntilContextLoadingHasFinished() { + public async waitUntilContextLoadingHasFinished() { return await retry.try(async () => { const successorLoadMoreButton = await this.getSuccessorLoadMoreButton(); const predecessorLoadMoreButton = await this.getPredecessorLoadMoreButton(); diff --git a/test/functional/page_objects/error_page.js b/test/functional/page_objects/error_page.ts similarity index 74% rename from test/functional/page_objects/error_page.js rename to test/functional/page_objects/error_page.ts index 8ae0bd554989e..332ce835d0b1c 100644 --- a/test/functional/page_objects/error_page.js +++ b/test/functional/page_objects/error_page.ts @@ -16,14 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import expect from '@kbn/expect'; -export function ErrorPageProvider({ getPageObjects }) { - const PageObjects = getPageObjects(['common']); +import expect from '@kbn/expect/expect.js'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { + const { common } = getPageObjects(['common']); class ErrorPage { - async expectForbidden() { - const messageText = await PageObjects.common.getBodyText(); + public async expectForbidden() { + const messageText = await common.getBodyText(); expect(messageText).to.eql( JSON.stringify({ statusCode: 403, @@ -32,8 +34,9 @@ export function ErrorPageProvider({ getPageObjects }) { }) ); } - async expectNotFound() { - const messageText = await PageObjects.common.getBodyText(); + + public async expectNotFound() { + const messageText = await common.getBodyText(); expect(messageText).to.eql( JSON.stringify({ statusCode: 404, diff --git a/test/functional/page_objects/header_page.js b/test/functional/page_objects/header_page.ts similarity index 87% rename from test/functional/page_objects/header_page.js rename to test/functional/page_objects/header_page.ts index d0a237e8f42d0..5f18034733822 100644 --- a/test/functional/page_objects/header_page.js +++ b/test/functional/page_objects/header_page.ts @@ -17,7 +17,9 @@ * under the License. */ -export function HeaderPageProvider({ getService, getPageObjects }) { +import { FtrProviderContext } from '../ftr_provider_context'; + +export function HeaderPageProvider({ getService, getPageObjects }: FtrProviderContext) { const config = getService('config'); const log = getService('log'); const retry = getService('retry'); @@ -29,13 +31,13 @@ export function HeaderPageProvider({ getService, getPageObjects }) { const defaultFindTimeout = config.get('timeouts.find'); class HeaderPage { - async clickDiscover() { + public async clickDiscover() { await appsMenu.clickLink('Discover'); await PageObjects.common.waitForTopNavToBeVisible(); await this.awaitGlobalLoadingIndicatorHidden(); } - async clickVisualize() { + public async clickVisualize() { await appsMenu.clickLink('Visualize'); await this.awaitGlobalLoadingIndicatorHidden(); await retry.waitFor('first breadcrumb to be "Visualize"', async () => { @@ -49,7 +51,7 @@ export function HeaderPageProvider({ getService, getPageObjects }) { }); } - async clickDashboard() { + public async clickDashboard() { await appsMenu.clickLink('Dashboard'); await retry.waitFor('dashboard app to be loaded', async () => { const isNavVisible = await testSubjects.exists('top-nav'); @@ -59,12 +61,12 @@ export function HeaderPageProvider({ getService, getPageObjects }) { await this.awaitGlobalLoadingIndicatorHidden(); } - async clickStackManagement() { + public async clickStackManagement() { await appsMenu.clickLink('Management'); await this.awaitGlobalLoadingIndicatorHidden(); } - async waitUntilLoadingHasFinished() { + public async waitUntilLoadingHasFinished() { try { await this.isGlobalLoadingIndicatorVisible(); } catch (exception) { @@ -77,19 +79,19 @@ export function HeaderPageProvider({ getService, getPageObjects }) { await this.awaitGlobalLoadingIndicatorHidden(); } - async isGlobalLoadingIndicatorVisible() { + public async isGlobalLoadingIndicatorVisible() { log.debug('isGlobalLoadingIndicatorVisible'); return await testSubjects.exists('globalLoadingIndicator', { timeout: 1500 }); } - async awaitGlobalLoadingIndicatorHidden() { + public async awaitGlobalLoadingIndicatorHidden() { await testSubjects.existOrFail('globalLoadingIndicator-hidden', { allowHidden: true, timeout: defaultFindTimeout * 10, }); } - async awaitKibanaChrome() { + public async awaitKibanaChrome() { log.debug('awaitKibanaChrome'); await testSubjects.find('kibanaChrome', defaultFindTimeout * 10); } diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index db58c3c2c7d19..01301109b80ef 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -18,30 +18,18 @@ */ import { CommonPageProvider } from './common_page'; -// @ts-ignore not TS yet import { ConsolePageProvider } from './console_page'; -// @ts-ignore not TS yet import { ContextPageProvider } from './context_page'; import { DashboardPageProvider } from './dashboard_page'; import { DiscoverPageProvider } from './discover_page'; -// @ts-ignore not TS yet import { ErrorPageProvider } from './error_page'; -// @ts-ignore not TS yet import { HeaderPageProvider } from './header_page'; import { HomePageProvider } from './home_page'; -// @ts-ignore not TS yet -import { MonitoringPageProvider } from './monitoring_page'; import { NewsfeedPageProvider } from './newsfeed_page'; -// @ts-ignore not TS yet -import { PointSeriesPageProvider } from './point_series_page'; -// @ts-ignore not TS yet import { SettingsPageProvider } from './settings_page'; import { SharePageProvider } from './share_page'; -// @ts-ignore not TS yet import { ShieldPageProvider } from './shield_page'; -// @ts-ignore not TS yet -import { TimePickerPageProvider } from './time_picker'; -// @ts-ignore not TS yet +import { TimePickerProvider } from './time_picker'; import { TimelionPageProvider } from './timelion_page'; import { VisualBuilderPageProvider } from './visual_builder_page'; import { VisualizePageProvider } from './visualize_page'; @@ -60,14 +48,12 @@ export const pageObjects = { error: ErrorPageProvider, header: HeaderPageProvider, home: HomePageProvider, - monitoring: MonitoringPageProvider, newsfeed: NewsfeedPageProvider, - pointSeries: PointSeriesPageProvider, settings: SettingsPageProvider, share: SharePageProvider, shield: ShieldPageProvider, timelion: TimelionPageProvider, - timePicker: TimePickerPageProvider, + timePicker: TimePickerProvider, visualBuilder: VisualBuilderPageProvider, visualize: VisualizePageProvider, visEditor: VisualizeEditorPageProvider, diff --git a/test/functional/page_objects/point_series_page.js b/test/functional/page_objects/point_series_page.js deleted file mode 100644 index 594facb8b74b5..0000000000000 --- a/test/functional/page_objects/point_series_page.js +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 function PointSeriesPageProvider({ getService }) { - const testSubjects = getService('testSubjects'); - const log = getService('log'); - const find = getService('find'); - - class PointSeriesVis { - async clickAddAxis() { - return await testSubjects.click('visualizeAddYAxisButton'); - } - - async setAxisTitle(title, { index = 0 } = {}) { - return await testSubjects.setValue(`valueAxisTitle${index}`, title); - } - - async getRightValueAxes() { - const axes = await find.allByCssSelector('.visAxis__column--right g.axis'); - return axes.length; - } - - async getHistogramSeries() { - const series = await find.allByCssSelector('.series.histogram'); - return series.length; - } - - async getGridLines() { - const grid = await find.byCssSelector('g.grid'); - const $ = await grid.parseDomContent(); - return $('path') - .toArray() - .map(line => { - const dAttribute = $(line).attr('d'); - const firstPoint = dAttribute - .split('L')[0] - .replace('M', '') - .split(','); - return { - x: parseFloat(firstPoint[0]), - y: parseFloat(firstPoint[1]), - }; - }); - } - - async toggleGridCategoryLines() { - return await testSubjects.click('showCategoryLines'); - } - - async setGridValueAxis(axis) { - log.debug(`setGridValueAxis(${axis})`); - await find.selectValue('select#gridAxis', axis); - } - - async setSeriesAxis(series, axis) { - await find.selectValue(`select#seriesValueAxis${series}`, axis); - } - - async setSeriesType(series, type) { - await find.selectValue(`select#seriesType${series}`, type); - } - } - - return new PointSeriesVis(); -} diff --git a/test/functional/page_objects/shield_page.js b/test/functional/page_objects/shield_page.ts similarity index 85% rename from test/functional/page_objects/shield_page.js rename to test/functional/page_objects/shield_page.ts index 4b85c65a12f2c..2b9c59373a8bc 100644 --- a/test/functional/page_objects/shield_page.js +++ b/test/functional/page_objects/shield_page.ts @@ -17,11 +17,13 @@ * under the License. */ -export function ShieldPageProvider({ getService }) { +import { FtrProviderContext } from '../ftr_provider_context'; + +export function ShieldPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); class ShieldPage { - async login(user, pwd) { + async login(user: string, pwd: string) { await testSubjects.setValue('loginUsername', user); await testSubjects.setValue('loginPassword', pwd); await testSubjects.click('loginSubmit'); diff --git a/test/functional/page_objects/time_picker.js b/test/functional/page_objects/time_picker.ts similarity index 77% rename from test/functional/page_objects/time_picker.js rename to test/functional/page_objects/time_picker.ts index 2394abc9c2185..92f0d090ff5ee 100644 --- a/test/functional/page_objects/time_picker.js +++ b/test/functional/page_objects/time_picker.ts @@ -18,65 +18,90 @@ */ import moment from 'moment'; +import { FtrProviderContext } from '../ftr_provider_context.d'; +import { WebElementWrapper } from '../services/lib/web_element_wrapper'; -export function TimePickerPageProvider({ getService, getPageObjects }) { +export function TimePickerProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); const find = getService('find'); const browser = getService('browser'); const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['header', 'common']); + const { header, common } = getPageObjects(['header', 'common']); + + type CommonlyUsed = + | 'Today' + | 'This_week' + | 'Last_15 minutes' + | 'Last_30 minutes' + | 'Last_1 hour' + | 'Last_24 hours' + | 'Last_7 days' + | 'Last_30 days' + | 'Last_90 days' + | 'Last_1 year'; + + class TimePicker { + defaultStartTime = 'Sep 19, 2015 @ 06:31:44.000'; + defaultEndTime = 'Sep 23, 2015 @ 18:31:44.000'; - class TimePickerPage { - async timePickerExists() { - return await testSubjects.exists('superDatePickerToggleQuickMenuButton'); - } - - formatDateToAbsoluteTimeString(date) { - // toISOString returns dates in format 'YYYY-MM-DDTHH:mm:ss.sssZ' - // Need to replace T with space and remove timezone - const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; - return moment(date).format(DEFAULT_DATE_FORMAT); + async setDefaultAbsoluteRange() { + await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); } - async getTimePickerPanel() { + private async getTimePickerPanel() { return await find.byCssSelector('div.euiPopover__panel-isOpen'); } - async waitPanelIsGone(panelElement) { + private async waitPanelIsGone(panelElement: WebElementWrapper) { await find.waitForElementStale(panelElement); } + public async timePickerExists() { + return await testSubjects.exists('superDatePickerToggleQuickMenuButton'); + } + /** - * @param {String} commonlyUsedOption 'superDatePickerCommonlyUsed_This_week' + * Sets commonly used time + * @param option 'Today' | 'This_week' | 'Last_15 minutes' | 'Last_24 hours' ... */ - async setCommonlyUsedTime(commonlyUsedOption) { + async setCommonlyUsedTime(option: CommonlyUsed) { await testSubjects.click('superDatePickerToggleQuickMenuButton'); - await testSubjects.click(commonlyUsedOption); + await testSubjects.click(`superDatePickerCommonlyUsed_${option}`); } - async inputValue(dataTestsubj, value) { + private async inputValue(dataTestSubj: string, value: string) { if (browser.isFirefox) { - const input = await testSubjects.find(dataTestsubj); + const input = await testSubjects.find(dataTestSubj); await input.clearValue(); await input.type(value); } else if (browser.isInternetExplorer) { - const input = await testSubjects.find(dataTestsubj); + const input = await testSubjects.find(dataTestSubj); const currentValue = await input.getAttribute('value'); await input.type(browser.keys.ARROW_RIGHT.repeat(currentValue.length)); await input.type(browser.keys.BACK_SPACE.repeat(currentValue.length)); await input.type(value); await input.click(); } else { - await testSubjects.setValue(dataTestsubj, value); + await testSubjects.setValue(dataTestSubj, value); } } + private async showStartEndTimes() { + // This first await makes sure the superDatePicker has loaded before we check for the ShowDatesButton + await testSubjects.exists('superDatePickerToggleQuickMenuButton', { timeout: 20000 }); + const isShowDatesButton = await testSubjects.exists('superDatePickerShowDatesButton'); + if (isShowDatesButton) { + await testSubjects.click('superDatePickerShowDatesButton'); + } + await testSubjects.exists('superDatePickerstartDatePopoverButton'); + } + /** * @param {String} fromTime MMM D, YYYY @ HH:mm:ss.SSS * @param {String} toTime MMM D, YYYY @ HH:mm:ss.SSS */ - async setAbsoluteRange(fromTime, toTime) { + public async setAbsoluteRange(fromTime: string, toTime: string) { log.debug(`Setting absolute range to ${fromTime} to ${toTime}`); await this.showStartEndTimes(); @@ -86,7 +111,7 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { await testSubjects.click('superDatePickerAbsoluteTab'); await testSubjects.click('superDatePickerAbsoluteDateInput'); await this.inputValue('superDatePickerAbsoluteDateInput', toTime); - await PageObjects.common.sleep(500); + await common.sleep(500); // set from time await testSubjects.click('superDatePickerstartDatePopoverButton'); @@ -110,30 +135,18 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { } await this.waitPanelIsGone(panel); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await header.awaitGlobalLoadingIndicatorHidden(); } - get defaultStartTime() { - return 'Sep 19, 2015 @ 06:31:44.000'; - } - get defaultEndTime() { - return 'Sep 23, 2015 @ 18:31:44.000'; + public async isOff() { + return await find.existsByCssSelector('.euiDatePickerRange--readOnly'); } - async setDefaultAbsoluteRange() { - await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); - } - - async isOff() { - const element = await find.byClassName('euiDatePickerRange--readOnly'); - return !!element; - } - - async isQuickSelectMenuOpen() { + public async isQuickSelectMenuOpen() { return await testSubjects.exists('superDatePickerQuickMenu'); } - async openQuickSelectTimeMenu() { + public async openQuickSelectTimeMenu() { log.debug('openQuickSelectTimeMenu'); const isMenuOpen = await this.isQuickSelectMenuOpen(); if (!isMenuOpen) { @@ -144,7 +157,7 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { } } - async closeQuickSelectTimeMenu() { + public async closeQuickSelectTimeMenu() { log.debug('closeQuickSelectTimeMenu'); const isMenuOpen = await this.isQuickSelectMenuOpen(); if (isMenuOpen) { @@ -155,17 +168,7 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { } } - async showStartEndTimes() { - // This first await makes sure the superDatePicker has loaded before we check for the ShowDatesButton - await testSubjects.exists('superDatePickerToggleQuickMenuButton', { timeout: 20000 }); - const isShowDatesButton = await testSubjects.exists('superDatePickerShowDatesButton'); - if (isShowDatesButton) { - await testSubjects.click('superDatePickerShowDatesButton'); - } - await testSubjects.exists('superDatePickerstartDatePopoverButton'); - } - - async getRefreshConfig(keepQuickSelectOpen = false) { + public async getRefreshConfig(keepQuickSelectOpen = false) { await this.openQuickSelectTimeMenu(); const interval = await testSubjects.getAttribute( 'superDatePickerRefreshIntervalInput', @@ -198,7 +201,7 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { }; } - async getTimeConfig() { + public async getTimeConfig() { await this.showStartEndTimes(); const start = await testSubjects.getVisibleText('superDatePickerstartDatePopoverButton'); const end = await testSubjects.getVisibleText('superDatePickerendDatePopoverButton'); @@ -208,15 +211,14 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { }; } - async getTimeDurationForSharing() { - return await retry.try(async () => { - const element = await testSubjects.find('dataSharedTimefilterDuration'); - const data = await element.getAttribute('data-shared-timefilter-duration'); - return data; - }); + public async getTimeDurationForSharing() { + return await testSubjects.getAttribute( + 'dataSharedTimefilterDuration', + 'data-shared-timefilter-duration' + ); } - async getTimeConfigAsAbsoluteTimes() { + public async getTimeConfigAsAbsoluteTimes() { await this.showStartEndTimes(); // get to time @@ -237,17 +239,15 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { }; } - async getTimeDurationInHours() { + public async getTimeDurationInHours() { const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; const { start, end } = await this.getTimeConfigAsAbsoluteTimes(); - const startMoment = moment(start, DEFAULT_DATE_FORMAT); const endMoment = moment(end, DEFAULT_DATE_FORMAT); - - return moment.duration(moment(endMoment) - moment(startMoment)).asHours(); + return moment.duration(endMoment.diff(startMoment)).asHours(); } - async pauseAutoRefresh() { + public async pauseAutoRefresh() { log.debug('pauseAutoRefresh'); const refreshConfig = await this.getRefreshConfig(true); if (!refreshConfig.isPaused) { @@ -259,7 +259,7 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { await this.closeQuickSelectTimeMenu(); } - async resumeAutoRefresh() { + public async resumeAutoRefresh() { log.debug('resumeAutoRefresh'); const refreshConfig = await this.getRefreshConfig(true); if (refreshConfig.isPaused) { @@ -270,22 +270,22 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { await this.closeQuickSelectTimeMenu(); } - async setHistoricalDataRange() { + public async setHistoricalDataRange() { await this.setDefaultAbsoluteRange(); } - async setDefaultDataRange() { + public async setDefaultDataRange() { const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; const toTime = 'Apr 13, 2018 @ 00:00:00.000'; await this.setAbsoluteRange(fromTime, toTime); } - async setLogstashDataRange() { + public async setLogstashDataRange() { const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; const toTime = 'Apr 13, 2018 @ 00:00:00.000'; await this.setAbsoluteRange(fromTime, toTime); } } - return new TimePickerPage(); + return new TimePicker(); } diff --git a/test/functional/page_objects/timelion_page.js b/test/functional/page_objects/timelion_page.ts similarity index 85% rename from test/functional/page_objects/timelion_page.js rename to test/functional/page_objects/timelion_page.ts index 88eda5da5ce15..1075c19a105c0 100644 --- a/test/functional/page_objects/timelion_page.js +++ b/test/functional/page_objects/timelion_page.ts @@ -17,7 +17,9 @@ * under the License. */ -export function TimelionPageProvider({ getService, getPageObjects }) { +import { FtrProviderContext } from '../ftr_provider_context'; + +export function TimelionPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const log = getService('log'); const PageObjects = getPageObjects(['common', 'header']); @@ -25,7 +27,7 @@ export function TimelionPageProvider({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); class TimelionPage { - async initTests() { + public async initTests() { await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*', }); @@ -36,29 +38,29 @@ export function TimelionPageProvider({ getService, getPageObjects }) { await PageObjects.common.navigateToApp('timelion'); } - async setExpression(expression) { + public async setExpression(expression: string) { const input = await testSubjects.find('timelionExpressionTextArea'); await input.clearValue(); await input.type(expression); } - async updateExpression(updates) { + public async updateExpression(updates: string) { const input = await testSubjects.find('timelionExpressionTextArea'); await input.type(updates); await PageObjects.common.sleep(500); } - async getExpression() { + public async getExpression() { const input = await testSubjects.find('timelionExpressionTextArea'); return input.getVisibleText(); } - async getSuggestionItemsText() { + public async getSuggestionItemsText() { const elements = await testSubjects.findAll('timelionSuggestionListItem'); return await Promise.all(elements.map(async element => await element.getVisibleText())); } - async clickSuggestion(suggestionIndex = 0, waitTime = 500) { + public async clickSuggestion(suggestionIndex = 0, waitTime = 500) { const elements = await testSubjects.findAll('timelionSuggestionListItem'); if (suggestionIndex > elements.length) { throw new Error( @@ -70,7 +72,7 @@ export function TimelionPageProvider({ getService, getPageObjects }) { await PageObjects.common.sleep(waitTime); } - async saveTimelionSheet() { + public async saveTimelionSheet() { await testSubjects.click('timelionSaveButton'); await testSubjects.click('timelionSaveAsSheetButton'); await testSubjects.click('timelionFinishSaveButton'); @@ -78,12 +80,12 @@ export function TimelionPageProvider({ getService, getPageObjects }) { await testSubjects.waitForDeleted('timelionSaveSuccessToast'); } - async expectWriteControls() { + public async expectWriteControls() { await testSubjects.existOrFail('timelionSaveButton'); await testSubjects.existOrFail('timelionDeleteButton'); } - async expectMissingWriteControls() { + public async expectMissingWriteControls() { await testSubjects.missingOrFail('timelionSaveButton'); await testSubjects.missingOrFail('timelionDeleteButton'); } diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 0f14489a39dbc..71e722a9c8fdd 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -379,6 +379,34 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr ); return values.filter(item => item.length > 0); } + + public async getRightValueAxes() { + const axes = await find.allByCssSelector('.visAxis__column--right g.axis'); + return axes.length; + } + + public async getHistogramSeries() { + const series = await find.allByCssSelector('.series.histogram'); + return series.length; + } + + public async getGridLines(): Promise> { + const grid = await find.byCssSelector('g.grid'); + const $ = await grid.parseDomContent(); + return $('path') + .toArray() + .map(line => { + const dAttribute = $(line).attr('d'); + const firstPoint = dAttribute + .split('L')[0] + .replace('M', '') + .split(','); + return { + x: parseFloat(firstPoint[0]), + y: parseFloat(firstPoint[1]), + }; + }); + } } return new VisualizeChart(); diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 41c12170cf4dc..8b0ec3ba26028 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -453,8 +453,8 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP return await comboBox.getComboBoxSelectedOptions('visEditorInterval'); } - public async getNumericInterval(agg = 2) { - return await testSubjects.getAttribute(`visEditorInterval${agg}`, 'value'); + public async getNumericInterval(aggNth = 2) { + return await testSubjects.getAttribute(`visEditorInterval${aggNth}`, 'value'); } public async clickMetricEditor() { @@ -487,6 +487,33 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } await options[optionIndex].click(); } + + // point series + + async clickAddAxis() { + return await testSubjects.click('visualizeAddYAxisButton'); + } + + async setAxisTitle(title: string, aggNth = 0) { + return await testSubjects.setValue(`valueAxisTitle${aggNth}`, title); + } + + public async toggleGridCategoryLines() { + return await testSubjects.click('showCategoryLines'); + } + + public async setGridValueAxis(axis: string) { + log.debug(`setGridValueAxis(${axis})`); + await find.selectValue('select#gridAxis', axis); + } + + public async setSeriesAxis(seriesNth: number, axis: string) { + await find.selectValue(`select#seriesValueAxis${seriesNth}`, axis); + } + + public async setSeriesType(seriesNth: number, type: string) { + await find.selectValue(`select#seriesType${seriesNth}`, type); + } } return new VisualizeEditorPage(); diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 220c2d8f6b363..8fa15fc8268ed 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -300,12 +300,25 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide } } - public async saveVisualization(vizName: string, { saveAsNew = false } = {}) { + public async saveVisualization( + vizName: string, + { saveAsNew = false, redirectToOrigin = false } = {} + ) { await this.ensureSavePanelOpen(); await testSubjects.setValue('savedObjectTitle', vizName); - if (saveAsNew) { - log.debug('Check save as new visualization'); - await testSubjects.click('saveAsNewCheckbox'); + + const saveAsNewCheckboxExists = await testSubjects.exists('saveAsNewCheckbox'); + if (saveAsNewCheckboxExists) { + const state = saveAsNew ? 'check' : 'uncheck'; + log.debug('save as new checkbox exists. Setting its state to', state); + await testSubjects.setEuiSwitch('saveAsNewCheckbox', state); + } + + const redirectToOriginCheckboxExists = await testSubjects.exists('returnToOriginModeSwitch'); + if (redirectToOriginCheckboxExists) { + const state = redirectToOrigin ? 'check' : 'uncheck'; + log.debug('redirect to origin checkbox exists. Setting its state to', state); + await testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); } log.debug('Click Save Visualization button'); @@ -320,8 +333,11 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide return message; } - public async saveVisualizationExpectSuccess(vizName: string, { saveAsNew = false } = {}) { - const saveMessage = await this.saveVisualization(vizName, { saveAsNew }); + public async saveVisualizationExpectSuccess( + vizName: string, + { saveAsNew = false, redirectToOrigin = false } = {} + ) { + const saveMessage = await this.saveVisualization(vizName, { saveAsNew, redirectToOrigin }); if (!saveMessage) { throw new Error( `Expected saveVisualization to respond with the saveMessage from the toast, got ${saveMessage}` @@ -331,14 +347,20 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide public async saveVisualizationExpectSuccessAndBreadcrumb( vizName: string, - { saveAsNew = false } = {} + { saveAsNew = false, redirectToOrigin = false } = {} ) { - await this.saveVisualizationExpectSuccess(vizName, { saveAsNew }); + await this.saveVisualizationExpectSuccess(vizName, { saveAsNew, redirectToOrigin }); await retry.waitFor( 'last breadcrumb to have new vis name', async () => (await globalNav.getLastBreadcrumb()) === vizName ); } + + public async saveVisualizationAndReturn() { + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('visualizesaveAndReturnButton'); + await testSubjects.click('visualizesaveAndReturnButton'); + } } return new VisualizePage(); diff --git a/test/functional/services/dashboard/visualizations.js b/test/functional/services/dashboard/visualizations.js index f7a6fb7d2f694..676e4c384fe36 100644 --- a/test/functional/services/dashboard/visualizations.js +++ b/test/functional/services/dashboard/visualizations.js @@ -116,7 +116,10 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }) { await PageObjects.visualize.clickMarkdownWidget(); await PageObjects.visEditor.setMarkdownTxt(markdown); await PageObjects.visEditor.clickGo(); - await PageObjects.visualize.saveVisualizationExpectSuccess(name); + await PageObjects.visualize.saveVisualizationExpectSuccess(name, { + saveAsNew: false, + redirectToOrigin: true, + }); } })(); } diff --git a/test/functional/services/test_subjects.ts b/test/functional/services/test_subjects.ts index e5c2e61c48a0b..090dc995ddc11 100644 --- a/test/functional/services/test_subjects.ts +++ b/test/functional/services/test_subjects.ts @@ -307,6 +307,7 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { await element.scrollIntoViewIfNecessary(); } + // isChecked always returns false when run on an euiSwitch, because they use the aria-checked attribute public async isChecked(selector: string) { const checkbox = await this.find(selector); return await checkbox.isSelected(); @@ -316,7 +317,22 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { const isChecked = await this.isChecked(selector); const states = { check: true, uncheck: false }; if (isChecked !== states[state]) { - log.debug(`updating checkbox ${selector}`); + log.debug(`updating checkbox ${selector} from ${isChecked} to ${states[state]}`); + await this.click(selector); + } + } + + public async isEuiSwitchChecked(selector: string) { + const euiSwitch = await this.find(selector); + const isChecked = await euiSwitch.getAttribute('aria-checked'); + return isChecked === 'true'; + } + + public async setEuiSwitch(selector: string, state: 'check' | 'uncheck') { + const isChecked = await this.isEuiSwitchChecked(selector); + const states = { check: true, uncheck: false }; + if (isChecked !== states[state]) { + log.debug(`updating checkbox ${selector} from ${isChecked} to ${states[state]}`); await this.click(selector); } } diff --git a/vars/slackNotifications.groovy b/vars/slackNotifications.groovy new file mode 100644 index 0000000000000..8ae37d1c44637 --- /dev/null +++ b/vars/slackNotifications.groovy @@ -0,0 +1,111 @@ +def getFailedBuildBlocks() { + def messages = [ + getFailedSteps(), + getTestFailures(), + ] + + return messages + .findAll { !!it } // No blank strings + .collect { markdownBlock(it) } +} + +def dividerBlock() { + return [ type: "divider" ] +} + +def markdownBlock(message) { + return [ + type: "section", + text: [ + type: "mrkdwn", + text: message, + ], + ] +} + +def contextBlock(message) { + return [ + type: "context", + elements: [ + [ + type: 'mrkdwn', + text: message, + ] + ] + ] +} + +def getFailedSteps() { + try { + def steps = jenkinsApi.getFailedSteps()?.findAll { step -> + step.displayName != 'Check out from version control' + } + + if (steps?.size() > 0) { + def list = steps.collect { "• <${it.logs}|${it.displayName}>" }.join("\n") + return "*Failed Steps*\n${list}" + } + } catch (ex) { + buildUtils.printStacktrace(ex) + print "Error retrieving failed pipeline steps for PR comment, will skip this section" + } + + return "" +} + +def getTestFailures() { + def failures = testUtils.getFailures() + if (!failures) { + return "" + } + + def messages = [] + messages << "*Test Failures*" + + def list = failures.collect { "• <${it.url}|${it.fullDisplayName}>" }.join("\n") + return "*Test Failures*\n${list}" +} + +def sendFailedBuild(Map params = [:]) { + def displayName = "${env.JOB_NAME} ${env.BUILD_DISPLAY_NAME}" + + def config = [ + channel: '#kibana-operations', + title: ":broken_heart: *<${env.BUILD_URL}|${displayName}>*", + message: ":broken_heart: ${displayName}", + color: 'danger', + icon: ':jenkins:', + username: 'Kibana Operations', + context: contextBlock("${displayName} · "), + ] + params + + def blocks = [markdownBlock(config.title)] + getFailedBuildBlocks().each { blocks << it } + blocks << dividerBlock() + blocks << config.context + + slackSend( + channel: config.channel, + username: config.username, + iconEmoji: config.icon, + color: config.color, + message: config.message, + blocks: blocks + ) +} + +def onFailure(Map options = [:], Closure closure) { + // try/finally will NOT work here, because the build status will not have been changed to ERROR when the finally{} block executes + catchError { + closure() + } + + def status = buildUtils.getBuildStatus() + if (status != "SUCCESS" && status != "UNSTABLE") { + catchErrors { + sendFailedBuild(options) + } + } +} + +return this diff --git a/x-pack/README.md b/x-pack/README.md index 42e54aa2f50f9..744d97ca02c75 100644 --- a/x-pack/README.md +++ b/x-pack/README.md @@ -12,7 +12,7 @@ Elasticsearch will run with a basic license. To run with a trial license, includ Example: `yarn es snapshot --license trial --password changeme` -By default, this will also set the password for native realm accounts to the password provided (`changeme` by default). This includes that of the `kibana` user which `elasticsearch.username` defaults to in development. If you wish to specific a password for a given native realm account, you can do that like so: `--password.kibana=notsecure` +By default, this will also set the password for native realm accounts to the password provided (`changeme` by default). This includes that of the `kibana_system` user which `elasticsearch.username` defaults to in development. If you wish to specify a password for a given native realm account, you can do that like so: `--password.kibana_system=notsecure` # Testing ## Running specific tests diff --git a/x-pack/index.js b/x-pack/index.js index ab29aaa2a10a2..99b63a49f5793 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -12,7 +12,6 @@ import { dashboardMode } from './legacy/plugins/dashboard_mode'; import { beats } from './legacy/plugins/beats_management'; import { maps } from './legacy/plugins/maps'; import { spaces } from './legacy/plugins/spaces'; -import { infra } from './legacy/plugins/infra'; import { taskManager } from './legacy/plugins/task_manager'; import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects'; import { ingestManager } from './legacy/plugins/ingest_manager'; @@ -27,7 +26,6 @@ module.exports = function(kibana) { dashboardMode(kibana), beats(kibana), maps(kibana), - infra(kibana), taskManager(kibana), encryptedSavedObjects(kibana), ingestManager(kibana), diff --git a/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js b/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js index a81483d1e7a17..a679010c67092 100644 --- a/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js +++ b/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js @@ -77,8 +77,6 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { }; }); -jest.mock('plugins/interpreter/registries', () => ({})); - // Disabling this test due to https://github.com/elastic/eui/issues/2242 jest.mock( '../public/components/workpad_header/share_menu/flyout/__examples__/share_website_flyout.stories', diff --git a/x-pack/legacy/plugins/canvas/README.md b/x-pack/legacy/plugins/canvas/README.md index 8e91161c635c2..fbcd674f72181 100644 --- a/x-pack/legacy/plugins/canvas/README.md +++ b/x-pack/legacy/plugins/canvas/README.md @@ -48,7 +48,7 @@ Open your plugin's `index.js` file, and modify it to look something like this (b export default function (kibana) { return new kibana.Plugin({ // Tell Kibana that this plugin needs canvas and the Kibana interpreter - require: ['interpreter', 'canvas'], + require: ['canvas'], // The name of your plugin. Make this whatever you want. name: 'canvas_example', @@ -132,7 +132,7 @@ In your plugin's root `index.js` file, modify the `kibana.Plugin` definition to export default function (kibana) { return new kibana.Plugin({ // Tell Kibana that this plugin needs canvas and the Kibana interpreter - require: ['interpreter', 'canvas'], + require: ['canvas'], // The name of your plugin. Make this whatever you want. name: 'canvas_example', diff --git a/x-pack/legacy/plugins/canvas/index.js b/x-pack/legacy/plugins/canvas/index.js index b62d88c930d91..d9ea54de2d8a8 100644 --- a/x-pack/legacy/plugins/canvas/index.js +++ b/x-pack/legacy/plugins/canvas/index.js @@ -5,14 +5,14 @@ */ import { resolve } from 'path'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { CANVAS_APP, CANVAS_TYPE, CUSTOM_ELEMENT_TYPE } from './common/lib'; export function canvas(kibana) { return new kibana.Plugin({ id: CANVAS_APP, configPrefix: 'xpack.canvas', - require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter'], + require: ['kibana', 'elasticsearch', 'xpack_main'], publicDir: resolve(__dirname, 'public'), uiExports: { app: { diff --git a/x-pack/legacy/plugins/dashboard_mode/index.js b/x-pack/legacy/plugins/dashboard_mode/index.js index ab90c6511de01..b3f6ad8dd5348 100644 --- a/x-pack/legacy/plugins/dashboard_mode/index.js +++ b/x-pack/legacy/plugins/dashboard_mode/index.js @@ -6,7 +6,7 @@ import { resolve } from 'path'; import { i18n } from '@kbn/i18n'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { CONFIG_DASHBOARD_ONLY_MODE_ROLES } from './common'; import { createDashboardModeRequestInterceptor } from './server'; diff --git a/x-pack/legacy/plugins/infra/index.ts b/x-pack/legacy/plugins/infra/index.ts deleted file mode 100644 index 6ef273924a346..0000000000000 --- a/x-pack/legacy/plugins/infra/index.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 { Root } from 'joi'; -import { savedObjectMappings } from '../../../plugins/infra/server'; - -export function infra(kibana: any) { - return new kibana.Plugin({ - id: 'infra', - configPrefix: 'xpack.infra', - require: ['kibana', 'elasticsearch'], - uiExports: { - mappings: savedObjectMappings, - }, - config(Joi: Root) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }) - .unknown() - .default(); - }, - }); -} diff --git a/x-pack/legacy/plugins/maps/index.js b/x-pack/legacy/plugins/maps/index.js index f51419165752b..5310b8f11252c 100644 --- a/x-pack/legacy/plugins/maps/index.js +++ b/x-pack/legacy/plugins/maps/index.js @@ -16,7 +16,7 @@ import { createMapPath, MAP_SAVED_OBJECT_TYPE, } from '../../../plugins/maps/common/constants'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; export function maps(kibana) { return new kibana.Plugin({ diff --git a/x-pack/legacy/plugins/maps/mappings.json b/x-pack/legacy/plugins/maps/mappings.json index 5e2e8c2c7e6e5..c939d096d7849 100644 --- a/x-pack/legacy/plugins/maps/mappings.json +++ b/x-pack/legacy/plugins/maps/mappings.json @@ -36,6 +36,12 @@ "indexPatternsWithGeoFieldCount": { "type": "long" }, + "indexPatternsWithGeoPointFieldCount": { + "type": "long" + }, + "indexPatternsWithGeoShapeFieldCount": { + "type": "long" + }, "mapsTotalCount": { "type": "long" }, diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js index 981a7f46e7c00..6131ff45c4a0f 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js @@ -20,6 +20,8 @@ describe('buildMapsTelemetry', () => { expect(result).toMatchObject({ indexPatternsWithGeoFieldCount: 0, + indexPatternsWithGeoPointFieldCount: 0, + indexPatternsWithGeoShapeFieldCount: 0, attributesPerMap: { dataSourcesCount: { avg: 0, @@ -45,7 +47,9 @@ describe('buildMapsTelemetry', () => { const result = buildMapsTelemetry({ mapSavedObjects, indexPatternSavedObjects, settings }); expect(result).toMatchObject({ - indexPatternsWithGeoFieldCount: 2, + indexPatternsWithGeoFieldCount: 3, + indexPatternsWithGeoPointFieldCount: 2, + indexPatternsWithGeoShapeFieldCount: 1, attributesPerMap: { dataSourcesCount: { avg: 2.6666666666666665, diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts index 4610baabad3fe..fe22c114cd921 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -61,13 +61,27 @@ function getIndexPatternsWithGeoFieldCount(indexPatterns: IIndexPattern[]) { ? JSON.parse(indexPattern.attributes.fields) : [] ); + const fieldListsWithGeoFields = fieldLists.filter(fields => fields.some( (field: IFieldType) => field.type === ES_GEO_FIELD_TYPE.GEO_POINT || field.type === ES_GEO_FIELD_TYPE.GEO_SHAPE ) ); - return fieldListsWithGeoFields.length; + + const fieldListsWithGeoPointFields = fieldLists.filter(fields => + fields.some((field: IFieldType) => field.type === ES_GEO_FIELD_TYPE.GEO_POINT) + ); + + const fieldListsWithGeoShapeFields = fieldLists.filter(fields => + fields.some((field: IFieldType) => field.type === ES_GEO_FIELD_TYPE.GEO_SHAPE) + ); + + return { + indexPatternsWithGeoFieldCount: fieldListsWithGeoFields.length, + indexPatternsWithGeoPointFieldCount: fieldListsWithGeoPointFields.length, + indexPatternsWithGeoShapeFieldCount: fieldListsWithGeoShapeFields.length, + }; } export function buildMapsTelemetry({ @@ -110,12 +124,16 @@ export function buildMapsTelemetry({ const dataSourcesCountSum = _.sum(dataSourcesCount); const layersCountSum = _.sum(layersCount); - const indexPatternsWithGeoFieldCount = getIndexPatternsWithGeoFieldCount( - indexPatternSavedObjects - ); + const { + indexPatternsWithGeoFieldCount, + indexPatternsWithGeoPointFieldCount, + indexPatternsWithGeoShapeFieldCount, + } = getIndexPatternsWithGeoFieldCount(indexPatternSavedObjects); return { settings, indexPatternsWithGeoFieldCount, + indexPatternsWithGeoPointFieldCount, + indexPatternsWithGeoShapeFieldCount, // Total count of maps mapsTotalCount: mapsCount, // Time of capture diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/test_resources/sample_index_pattern_saved_objects.json b/x-pack/legacy/plugins/maps/server/maps_telemetry/test_resources/sample_index_pattern_saved_objects.json index bb30a60f6d69f..0b36d5ff84016 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/test_resources/sample_index_pattern_saved_objects.json +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/test_resources/sample_index_pattern_saved_objects.json @@ -28,6 +28,20 @@ "updated_at": "2019-11-19T20:05:37.607Z", "version": "WzExMSwxXQ==" }, + { + "attributes": { + "fields": "[{\"name\":\"geometry\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "title": "indexpattern-with-geopoint2" + }, + "id": "55d572f0-0b07-11ea-9dd2-95afd7ad44d4", + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2019-11-19T20:05:37.607Z", + "version": "WzExMSwxXQ==" + }, { "attributes": { "fields": "[{\"name\":\"assessment_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"date_exterior_condition\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"recording_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sale_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", diff --git a/x-pack/legacy/plugins/monitoring/README.md b/x-pack/legacy/plugins/monitoring/README.md index e9ececa8c6350..0222f06e7ae91 100644 --- a/x-pack/legacy/plugins/monitoring/README.md +++ b/x-pack/legacy/plugins/monitoring/README.md @@ -74,7 +74,7 @@ cluster. % cat config/kibana.dev.yml monitoring.ui.elasticsearch: hosts: "http://localhost:9210" - username: "kibana" + username: "kibana_system" password: "changeme" ``` diff --git a/x-pack/package.json b/x-pack/package.json index 5d1fbaa5784e0..5461b21b571f8 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -268,7 +268,7 @@ "io-ts": "^2.0.5", "isbinaryfile": "4.0.2", "joi": "^13.5.2", - "jquery": "^3.4.1", + "jquery": "^3.5.0", "js-search": "^1.4.3", "js-yaml": "3.13.1", "json-stable-stringify": "^1.0.1", diff --git a/x-pack/plugins/actions/server/lib/license_state.ts b/x-pack/plugins/actions/server/lib/license_state.ts index ae7180c4658bc..914aada08bb2c 100644 --- a/x-pack/plugins/actions/server/lib/license_state.ts +++ b/x-pack/plugins/actions/server/lib/license_state.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { Observable, Subscription } from 'rxjs'; -import { assertNever } from '../../../../../src/core/utils'; +import { assertNever } from '../../../../../src/core/server'; import { ILicense } from '../../../licensing/common/types'; import { PLUGIN } from '../constants/plugin'; import { ActionType } from '../types'; diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 62c2caed669af..d15b5f4ce45cf 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -87,6 +87,7 @@ The following table describes the properties of the `options` object. |id|Unique identifier for the alert type. For convention purposes, ids starting with `.` are reserved for built in alert types. We recommend using a convention like `.mySpecialAlert` for your alert types to avoid conflicting with another plugin.|string| |name|A user-friendly name for the alert type. These will be displayed in dropdowns when choosing alert types.|string| |actionGroups|An explicit list of groups the alert type may schedule actions for, each specifying the ActionGroup's unique ID and human readable name. Alert `actions` validation will use this configuartion to ensure groups are valid. We highly encourage using `kbn-i18n` to translate the names of actionGroup when registering the AlertType. |Array<{id:string, name:string}>| +|defaultActionGroupId|Default ID value for the group of the alert type.|string| |actionVariables|An explicit list of action variables the alert type makes available via context and state in action parameter templates, and a short human readable description. Alert UI will use this to display prompts for the users for these variables, in action parameter editors. We highly encourage using `kbn-i18n` to translate the descriptions. |{ context: Array<{name:string, description:string}, state: Array<{name:string, description:string}>| |validate.params|When developing an alert type, you can choose to accept a series of parameters. You may also have the parameters validated before they are passed to the `executor` function or created as an alert saved object. In order to do this, provide a `@kbn/config-schema` schema that we will use to validate the `params` attribute.|@kbn/config-schema| |executor|This is where the code of the alert type lives. This is a function to be called when executing an alert on an interval basis. For full details, see executor section below.|Function| @@ -102,6 +103,7 @@ This is the primary function for an alert type. Whenever the alert needs to exec |services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but in the context of the user who created the alert when security is enabled.| |services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user who created the alert (only when security isenabled).| |services.getScopedCallCluster|This function scopes an instance of CallCluster by returning a `callCluster(path, opts)` function that runs in the context of the user who created the alert when security is enabled. This must only be called with instances of CallCluster provided by core.| +|services.alertInstanceFactory(id)|This [alert instance factory](#alert-instance-factory) creates instances of alerts and must be used in order to execute actions. The id you give to the alert instance factory is a unique identifier to the alert instance.| |services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| |startedAt|The date and time the alert type started execution.| |previousStartedAt|The previous date and time the alert type started a successful execution.| @@ -117,7 +119,7 @@ This is the primary function for an alert type. Whenever the alert needs to exec ### The `actionVariables` property -This property should contain the **flattened** names of the state and context variables available when an executor calls `alertInstance.scheduleActions(groupName, context)`. These names are meant to be used in prompters in the alerting user interface, are used as text values for display, and can be inserted into to an action parameter text entry field via UI gesture (eg, clicking a menu item from a menu built with these names). They should be flattened, so if a state or context variable is an object with properties, these should be listed with the "parent" property/properties in the name, separated by a `.` (period). +This property should contain the **flattened** names of the state and context variables available when an executor calls `alertInstance.scheduleActions(actionGroup, context)`. These names are meant to be used in prompters in the alerting user interface, are used as text values for display, and can be inserted into to an action parameter text entry field via UI gesture (eg, clicking a menu item from a menu built with these names). They should be flattened, so if a state or context variable is an object with properties, these should be listed with the "parent" property/properties in the name, separated by a `.` (period). For example, if the `context` has one variable `foo` which is an object that has one property `bar`, and there are no `state` variables, the `actionVariables` value would be in the following shape: @@ -145,6 +147,17 @@ server.newPlatform.setup.plugins.alerting.registerType({ threshold: schema.number({ min: 0, max: 1 }), }), }, + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + { + id: 'warning', + name: 'Warning', + }, + ], + defaultActionGroupId: 'default', actionVariables: { context: [ { name: 'server', description: 'the server' }, @@ -184,7 +197,7 @@ server.newPlatform.setup.plugins.alerting.registerType({ cpuUsage: currentCpuUsage, }); - // 'default' refers to a group of actions to be scheduled for execution, see 'actions' in create alert section + // 'default' refers to the id of a group of actions to be scheduled for execution, see 'actions' in create alert section alertInstance.scheduleActions('default', { server, hasCpuUsageIncreased: currentCpuUsage > previousCpuUsage, @@ -213,6 +226,13 @@ server.newPlatform.setup.plugins.alerting.registerType({ threshold: schema.number({ min: 0, max: 1 }), }), }, + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', actionVariables: { context: [ { name: 'server', description: 'the server' }, @@ -253,7 +273,7 @@ server.newPlatform.setup.plugins.alerting.registerType({ cpuUsage: currentCpuUsage, }); - // 'default' refers to a group of actions to be scheduled for execution, see 'actions' in create alert section + // 'default' refers to the id of a group of actions to be scheduled for execution, see 'actions' in create alert section alertInstance.scheduleActions('default', { server, hasCpuUsageIncreased: currentCpuUsage > previousCpuUsage, @@ -472,7 +492,7 @@ This factory returns an instance of `AlertInstance`. The alert instance class ha |Method|Description| |---|---| |getState()|Get the current state of the alert instance.| -|scheduleActions(actionGroup, context)|Called to schedule the execution of actions. The actionGroup relates to the group of alert `actions` to execute and the context will be used for templating purposes. This should only be called once per alert instance.| +|scheduleActions(actionGroup, context)|Called to schedule the execution of actions. The actionGroup is a string `id` that relates to the group of alert `actions` to execute and the context will be used for templating purposes. This should only be called once per alert instance.| |replaceState(state)|Used to replace the current state of the alert instance. This doesn't work like react, the entire state must be provided. Use this feature as you see fit. The state that is set will persist between alert type executions whenever you re-create an alert instance with the same id. The instance state will be erased when `scheduleActions` isn't called during an execution.| ## Templating actions @@ -489,6 +509,8 @@ When an alert instance executes, the first argument is the `group` of actions to - `spaceId` - the id of the space the alert exists in - `tags` - the tags set in the alert +The templating engine is [mustache]. General definition for the [mustache variable] is a double-brace {{}}. All variables are HTML-escaped by default and if there is a requirement to render unescaped HTML, it should be applied the triple mustache: `{{{name}}}`. Also, can be used `&` to unescape a variable. + ## Examples The following code would be within an alert type. As you can see `cpuUsage ` will replace the state of the alert instance and `server` is the context for the alert instance to execute. The difference between the two is `cpuUsage ` will be accessible at the next execution. @@ -537,3 +559,6 @@ The templating system will take the alert and alert type as described above and ``` There are limitations that we are aware of using only templates, and we are gathering feedback and use cases for these. (for example passing an array of strings to an action). + +[mustache]: https://github.com/janl/mustache.js +[mustache variable]: https://github.com/janl/mustache.js#variables diff --git a/x-pack/plugins/alerting/server/lib/license_state.ts b/x-pack/plugins/alerting/server/lib/license_state.ts index db60d64db5df4..211d7a75dc4fa 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.ts @@ -8,7 +8,7 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; import { Observable, Subscription } from 'rxjs'; import { ILicense } from '../../../../plugins/licensing/common/types'; -import { assertNever } from '../../../../../src/core/utils'; +import { assertNever } from '../../../../../src/core/server'; import { PLUGIN } from '../constants/plugin'; export interface AlertingLicenseInformation { diff --git a/x-pack/plugins/apm/common/annotations.ts b/x-pack/plugins/apm/common/annotations.ts index 33122f55d8800..264236e22b0c1 100644 --- a/x-pack/plugins/apm/common/annotations.ts +++ b/x-pack/plugins/apm/common/annotations.ts @@ -11,6 +11,6 @@ export enum AnnotationType { export interface Annotation { type: AnnotationType; id: string; - time: number; + '@timestamp': number; text: string; } diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 75c1c945c5d26..2ff30a61499b6 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -9,7 +9,6 @@ import { ILicense } from '../../licensing/public'; import { AGENT_NAME, SERVICE_ENVIRONMENT, - SERVICE_FRAMEWORK_NAME, SERVICE_NAME, SPAN_SUBTYPE, SPAN_TYPE, @@ -19,7 +18,6 @@ import { export interface ServiceConnectionNode { [SERVICE_NAME]: string; [SERVICE_ENVIRONMENT]: string | null; - [SERVICE_FRAMEWORK_NAME]: string | null; [AGENT_NAME]: string; } export interface ExternalConnectionNode { diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 85e3761129018..7c267efedf1af 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -16,9 +16,13 @@ "taskManager", "actions", "alerting", + "observability", "security" ], "server": true, "ui": true, - "configPath": ["xpack", "apm"] + "configPath": [ + "xpack", + "apm" + ] } diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index c3738329219a8..314bd722274de 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -74,6 +74,7 @@ const ApmAppRoot = ({ value={{ http: core.http, docLinks: core.docLinks, + capabilities: core.application.capabilities, toastNotifications: core.notifications.toasts, actionTypeRegistry: plugins.triggers_actions_ui.actionTypeRegistry, alertTypeRegistry: plugins.triggers_actions_ui.alertTypeRegistry diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index ad77434bca9f4..797145368b4b7 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -134,6 +134,11 @@ export function Cytoscape({ ); cy.remove(absentElements); cy.add(elements); + // ensure all elements get latest data properties + elements.forEach(elementDefinition => { + const el = cy.getElementById(elementDefinition.data.id as string); + el.data(elementDefinition.data); + }); cy.trigger('data'); } }, [cy, elements]); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index bc3434f277d1c..7e15d0116b84d 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -8,14 +8,23 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, - EuiTitle + EuiTitle, + EuiIconTip, + EuiHealth } from '@elastic/eui'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; import cytoscape from 'cytoscape'; import React from 'react'; -import { SERVICE_FRAMEWORK_NAME } from '../../../../../common/elasticsearch_fieldnames'; +import styled from 'styled-components'; +import { fontSize, px } from '../../../../style/variables'; import { Buttons } from './Buttons'; import { Info } from './Info'; import { ServiceMetricFetcher } from './ServiceMetricFetcher'; +import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; +import { getSeverityColor } from '../cytoscapeOptions'; +import { asInteger } from '../../../../utils/formatters'; +import { getMetricChangeDescription } from '../../../../../../ml/public'; const popoverMinWidth = 280; @@ -27,6 +36,31 @@ interface ContentsProps { selectedNodeServiceName: string; } +const HealthStatusTitle = styled(EuiTitle)` + display: inline; + text-transform: uppercase; +`; + +const VerticallyCentered = styled.div` + display: flex; + align-items: center; +`; + +const SubduedText = styled.span` + color: ${theme.euiTextSubduedColor}; +`; + +const EnableText = styled.section` + color: ${theme.euiTextSubduedColor}; + line-height: 1.4; + font-size: ${fontSize}; + width: ${px(popoverMinWidth)}; +`; + +export const ContentLine = styled.section` + line-height: 2; +`; + // IE 11 does not handle flex properties as expected. With browser detection, // we can use regular div elements to render contents that are almost identical. // @@ -51,6 +85,37 @@ const FlexColumnGroup = (props: { const FlexColumnItem = (props: { children: React.ReactNode }) => isIE11 ?
: ; +const ANOMALY_DETECTION_TITLE = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverTitle', + { defaultMessage: 'Anomaly Detection' } +); + +const ANOMALY_DETECTION_INFO = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverInfo', + { + defaultMessage: + 'Display the health of your service by enabling the anomaly detection feature in Machine Learning.' + } +); + +const ANOMALY_DETECTION_SCORE_METRIC = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric', + { defaultMessage: 'Score (max.)' } +); + +const ANOMALY_DETECTION_LINK = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverLink', + { defaultMessage: 'View anomalies' } +); + +const ANOMALY_DETECTION_ENABLE_TEXT = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverEnable', + { + defaultMessage: + 'Enable anomaly detection from the Integrations menu in the Service details view.' + } +); + export function Contents({ selectedNodeData, isService, @@ -58,7 +123,23 @@ export function Contents({ onFocusClick, selectedNodeServiceName }: ContentsProps) { - const frameworkName = selectedNodeData[SERVICE_FRAMEWORK_NAME]; + // Anomaly Detection + const severity = selectedNodeData.severity; + const maxScore = selectedNodeData.max_score; + const actualValue = selectedNodeData.actual_value; + const typicalValue = selectedNodeData.typical_value; + const jobId = selectedNodeData.job_id; + const hasAnomalyDetection = [ + severity, + maxScore, + actualValue, + typicalValue, + jobId + ].every(value => value !== undefined); + const anomalyDescription = hasAnomalyDetection + ? getMetricChangeDescription(actualValue, typicalValue).message + : null; + return ( + {isService && ( + +
+ +

{ANOMALY_DETECTION_TITLE}

+
+   + +
+ {hasAnomalyDetection ? ( + <> + + + + + + + {ANOMALY_DETECTION_SCORE_METRIC} + + + + +
+ {asInteger(maxScore)} +  ({anomalyDescription}) +
+
+
+
+ + + {ANOMALY_DETECTION_LINK} + + + + ) : ( + {ANOMALY_DETECTION_ENABLE_TEXT} + )} + +
+ )} {isService ? ( - + ) : ( )} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index 23e9e737be9a6..e5962afd76eb8 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -16,7 +16,6 @@ storiesOf('app/ServiceMap/Popover/ServiceMetricList', module) avgRequestsPerMinute={164.47222031860858} avgCpuUsage={0.32809666568309237} avgMemoryUsage={0.5504868173242986} - frameworkName="Spring" numInstances={2} isLoading={false} /> diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx index 5e6412333a2e1..6f67f7a4bed7a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx @@ -11,12 +11,10 @@ import { useUrlParams } from '../../../../hooks/useUrlParams'; import { ServiceMetricList } from './ServiceMetricList'; interface ServiceMetricFetcherProps { - frameworkName?: string; serviceName: string; } export function ServiceMetricFetcher({ - frameworkName, serviceName }: ServiceMetricFetcherProps) { const { @@ -39,11 +37,5 @@ export function ServiceMetricFetcher({ ); const isLoading = status === 'loading'; - return ( - - ); + return ; } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx index 3cee986261a68..5c28fc0a5a7d0 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx @@ -34,21 +34,20 @@ const BadgeRow = styled(EuiFlexItem)` padding-bottom: ${lightTheme.gutterTypes.gutterSmall}; `; -const ItemRow = styled('tr')` +export const ItemRow = styled('tr')` line-height: 2; `; -const ItemTitle = styled('td')` +export const ItemTitle = styled('td')` color: ${lightTheme.textColors.subdued}; padding-right: 1rem; `; -const ItemDescription = styled('td')` +export const ItemDescription = styled('td')` text-align: right; `; interface ServiceMetricListProps extends ServiceNodeMetrics { - frameworkName?: string; isLoading: boolean; } @@ -58,7 +57,6 @@ export function ServiceMetricList({ avgErrorsPerMinute, avgCpuUsage, avgMemoryUsage, - frameworkName, numInstances, isLoading }: ServiceMetricListProps) { @@ -112,7 +110,7 @@ export function ServiceMetricList({ : null } ]; - const showBadgeRow = frameworkName || numInstances > 1; + const showBadgeRow = numInstances > 1; return isLoading ? ( @@ -121,7 +119,6 @@ export function ServiceMetricList({ {showBadgeRow && ( - {frameworkName && {frameworkName}} {numInstances > 1 && ( {i18n.translate('xpack.apm.serviceMap.numInstancesMetric', { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape-layout-test-response.json b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape-layout-test-response.json index 4b4b5c2ed802e..e55ba65bcbcb9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape-layout-test-response.json +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape-layout-test-response.json @@ -3,38 +3,19 @@ { "data": { "source": "apm-server", - "target": ">172.17.0.1", - "id": "apm-server~>172.17.0.1", + "target": ">elasticsearch", + "id": "apm-server~>elasticsearch", "sourceData": { - "service.environment": null, + "id": "apm-server", "service.name": "apm-server", - "agent.name": "go", - "id": "apm-server" - }, - "targetData": { - "destination.address": "172.17.0.1", - "span.subtype": "http", - "span.type": "external", - "id": ">172.17.0.1" - } - } - }, - { - "data": { - "source": "client", - "target": ">opbeans-node", - "id": "client~>opbeans-node", - "sourceData": { - "service.environment": null, - "service.name": "client", - "agent.name": "js-base", - "id": "client" + "agent.name": "go" }, "targetData": { - "destination.address": "opbeans-node", - "span.subtype": null, - "span.type": "resource", - "id": ">opbeans-node" + "span.subtype": "elasticsearch", + "span.destination.service.resource": "elasticsearch", + "span.type": "db", + "id": ">elasticsearch", + "label": "elasticsearch" } } }, @@ -44,135 +25,39 @@ "target": "opbeans-node", "id": "client~opbeans-node", "sourceData": { - "service.environment": null, + "id": "client", "service.name": "client", - "agent.name": "js-base", - "id": "client" + "agent.name": "rum-js" }, "targetData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" } } }, - { - "data": { - "source": "opbeans-dotnet", - "target": "opbeans-go", - "id": "opbeans-dotnet~opbeans-go", - "sourceData": { - "service.environment": "production", - "service.name": "opbeans-dotnet", - "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "targetData": { - "service.environment": "production", - "service.name": "opbeans-go", - "agent.name": "go", - "id": "opbeans-go" - }, - "bidirectional": true - } - }, - { - "data": { - "source": "opbeans-dotnet", - "target": "opbeans-java", - "id": "opbeans-dotnet~opbeans-java", - "sourceData": { - "service.environment": "production", - "service.name": "opbeans-dotnet", - "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "targetData": { - "service.environment": "production", - "service.name": "opbeans-java", - "agent.name": "java", - "id": "opbeans-java" - }, - "bidirectional": true - } - }, - { - "data": { - "source": "opbeans-dotnet", - "target": "opbeans-node", - "id": "opbeans-dotnet~opbeans-node", - "sourceData": { - "service.environment": "production", - "service.name": "opbeans-dotnet", - "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "targetData": { - "service.environment": "production", - "service.name": "opbeans-node", - "agent.name": "nodejs", - "id": "opbeans-node" - }, - "bidirectional": true - } - }, - { - "data": { - "source": "opbeans-dotnet", - "target": "opbeans-python", - "id": "opbeans-dotnet~opbeans-python", - "sourceData": { - "service.environment": "production", - "service.name": "opbeans-dotnet", - "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "targetData": { - "service.environment": "production", - "service.name": "opbeans-python", - "agent.name": "python", - "id": "opbeans-python" - }, - "bidirectional": true - } - }, - { - "data": { - "source": "opbeans-dotnet", - "target": "opbeans-ruby", - "id": "opbeans-dotnet~opbeans-ruby", - "sourceData": { - "service.environment": "production", - "service.name": "opbeans-dotnet", - "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "targetData": { - "service.environment": "production", - "service.name": "opbeans-ruby", - "agent.name": "ruby", - "id": "opbeans-ruby" - }, - "bidirectional": true - } - }, { "data": { "source": "opbeans-go", - "target": ">postgres", - "id": "opbeans-go~>postgres", + "target": ">postgresql", + "id": "opbeans-go~>postgresql", "sourceData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "targetData": { - "destination.address": "postgres", "span.subtype": "postgresql", + "span.destination.service.resource": "postgresql", "span.type": "db", - "id": ">postgres" + "id": ">postgresql", + "label": "postgresql" } } }, @@ -182,18 +67,22 @@ "target": "opbeans-dotnet", "id": "opbeans-go~opbeans-dotnet", "sourceData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "targetData": { - "service.environment": "production", + "id": "opbeans-dotnet", "service.name": "opbeans-dotnet", "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "isInverseEdge": true + "service.framework.name": "ASP.NET Core", + "max_score": 43.63972429875661, + "severity": "minor" + } } }, { @@ -202,16 +91,21 @@ "target": "opbeans-java", "id": "opbeans-go~opbeans-java", "sourceData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "targetData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "bidirectional": true } @@ -222,16 +116,20 @@ "target": "opbeans-node", "id": "opbeans-go~opbeans-node", "sourceData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "targetData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" }, "bidirectional": true } @@ -242,16 +140,22 @@ "target": "opbeans-python", "id": "opbeans-go~opbeans-python", "sourceData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "targetData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "bidirectional": true } @@ -262,16 +166,20 @@ "target": "opbeans-ruby", "id": "opbeans-go~opbeans-ruby", "sourceData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "targetData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "bidirectional": true } @@ -279,19 +187,22 @@ { "data": { "source": "opbeans-java", - "target": ">postgres", - "id": "opbeans-java~>postgres", + "target": ">postgresql", + "id": "opbeans-java~>postgresql", "sourceData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "targetData": { - "destination.address": "postgres", "span.subtype": "postgresql", + "span.destination.service.resource": "postgresql", "span.type": "db", - "id": ">postgres" + "id": ">postgresql", + "label": "postgresql" } } }, @@ -301,18 +212,21 @@ "target": "opbeans-dotnet", "id": "opbeans-java~opbeans-dotnet", "sourceData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "targetData": { - "service.environment": "production", + "id": "opbeans-dotnet", "service.name": "opbeans-dotnet", "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "isInverseEdge": true + "service.framework.name": "ASP.NET Core", + "max_score": 43.63972429875661, + "severity": "minor" + } } }, { @@ -321,16 +235,21 @@ "target": "opbeans-go", "id": "opbeans-java~opbeans-go", "sourceData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "targetData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "isInverseEdge": true } @@ -341,16 +260,19 @@ "target": "opbeans-node", "id": "opbeans-java~opbeans-node", "sourceData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "targetData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" }, "bidirectional": true } @@ -361,16 +283,21 @@ "target": "opbeans-python", "id": "opbeans-java~opbeans-python", "sourceData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "targetData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "bidirectional": true } @@ -381,56 +308,43 @@ "target": "opbeans-ruby", "id": "opbeans-java~opbeans-ruby", "sourceData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "targetData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "bidirectional": true } }, - { - "data": { - "source": "opbeans-node", - "target": "opbeans-dotnet", - "id": "opbeans-node~opbeans-dotnet", - "sourceData": { - "service.environment": "production", - "service.name": "opbeans-node", - "agent.name": "nodejs", - "id": "opbeans-node" - }, - "targetData": { - "service.environment": "production", - "service.name": "opbeans-dotnet", - "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "isInverseEdge": true - } - }, { "data": { "source": "opbeans-node", "target": "opbeans-go", "id": "opbeans-node~opbeans-go", "sourceData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" }, "targetData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "isInverseEdge": true } @@ -441,16 +355,19 @@ "target": "opbeans-java", "id": "opbeans-node~opbeans-java", "sourceData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" }, "targetData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "isInverseEdge": true } @@ -461,16 +378,20 @@ "target": "opbeans-python", "id": "opbeans-node~opbeans-python", "sourceData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" }, "targetData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "bidirectional": true } @@ -481,38 +402,113 @@ "target": "opbeans-ruby", "id": "opbeans-node~opbeans-ruby", "sourceData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" }, "targetData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "bidirectional": true } }, + { + "data": { + "source": "opbeans-python", + "target": ">elasticsearch", + "id": "opbeans-python~>elasticsearch", + "sourceData": { + "id": "opbeans-python", + "service.environment": "production", + "service.name": "opbeans-python", + "agent.name": "python", + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" + }, + "targetData": { + "span.subtype": "elasticsearch", + "span.destination.service.resource": "elasticsearch", + "span.type": "db", + "id": ">elasticsearch", + "label": "elasticsearch" + } + } + }, + { + "data": { + "source": "opbeans-python", + "target": ">postgresql", + "id": "opbeans-python~>postgresql", + "sourceData": { + "id": "opbeans-python", + "service.environment": "production", + "service.name": "opbeans-python", + "agent.name": "python", + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" + }, + "targetData": { + "span.subtype": "postgresql", + "span.destination.service.resource": "postgresql", + "span.type": "db", + "id": ">postgresql", + "label": "postgresql" + } + } + }, + { + "data": { + "source": "opbeans-python", + "target": ">redis", + "id": "opbeans-python~>redis", + "sourceData": { + "id": "opbeans-python", + "service.environment": "production", + "service.name": "opbeans-python", + "agent.name": "python", + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" + }, + "targetData": { + "span.subtype": "redis", + "span.destination.service.resource": "redis", + "span.type": "db", + "id": ">redis", + "label": "redis" + } + } + }, { "data": { "source": "opbeans-python", "target": "opbeans-dotnet", "id": "opbeans-python~opbeans-dotnet", "sourceData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "targetData": { - "service.environment": "production", + "id": "opbeans-dotnet", "service.name": "opbeans-dotnet", "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "isInverseEdge": true + "service.framework.name": "ASP.NET Core", + "max_score": 43.63972429875661, + "severity": "minor" + } } }, { @@ -521,16 +517,22 @@ "target": "opbeans-go", "id": "opbeans-python~opbeans-go", "sourceData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "targetData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "isInverseEdge": true } @@ -541,16 +543,21 @@ "target": "opbeans-java", "id": "opbeans-python~opbeans-java", "sourceData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "targetData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "isInverseEdge": true } @@ -561,16 +568,20 @@ "target": "opbeans-node", "id": "opbeans-python~opbeans-node", "sourceData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "targetData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" }, "isInverseEdge": true } @@ -581,38 +592,65 @@ "target": "opbeans-ruby", "id": "opbeans-python~opbeans-ruby", "sourceData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "targetData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "bidirectional": true } }, + { + "data": { + "source": "opbeans-ruby", + "target": ">postgresql", + "id": "opbeans-ruby~>postgresql", + "sourceData": { + "id": "opbeans-ruby", + "service.environment": "production", + "service.name": "opbeans-ruby", + "agent.name": "ruby", + "service.framework.name": "Ruby on Rails" + }, + "targetData": { + "span.subtype": "postgresql", + "span.destination.service.resource": "postgresql", + "span.type": "db", + "id": ">postgresql", + "label": "postgresql" + } + } + }, { "data": { "source": "opbeans-ruby", "target": "opbeans-dotnet", "id": "opbeans-ruby~opbeans-dotnet", "sourceData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "targetData": { - "service.environment": "production", + "id": "opbeans-dotnet", "service.name": "opbeans-dotnet", "agent.name": "dotnet", - "id": "opbeans-dotnet" - }, - "isInverseEdge": true + "service.framework.name": "ASP.NET Core", + "max_score": 43.63972429875661, + "severity": "minor" + } } }, { @@ -621,16 +659,20 @@ "target": "opbeans-go", "id": "opbeans-ruby~opbeans-go", "sourceData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "targetData": { + "id": "opbeans-go", "service.environment": "production", "service.name": "opbeans-go", "agent.name": "go", - "id": "opbeans-go" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" }, "isInverseEdge": true } @@ -641,16 +683,19 @@ "target": "opbeans-java", "id": "opbeans-ruby~opbeans-java", "sourceData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "targetData": { + "id": "opbeans-java", "service.environment": "production", "service.name": "opbeans-java", "agent.name": "java", - "id": "opbeans-java" + "max_score": 31.374423806075157, + "severity": "minor" }, "isInverseEdge": true } @@ -661,16 +706,18 @@ "target": "opbeans-node", "id": "opbeans-ruby~opbeans-node", "sourceData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "targetData": { + "id": "opbeans-node", "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "id": "opbeans-node" + "service.framework.name": "express" }, "isInverseEdge": true } @@ -681,178 +728,123 @@ "target": "opbeans-python", "id": "opbeans-ruby~opbeans-python", "sourceData": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" + "service.framework.name": "Ruby on Rails" }, "targetData": { + "id": "opbeans-python", "service.environment": "production", "service.name": "opbeans-python", "agent.name": "python", - "id": "opbeans-python" + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" }, "isInverseEdge": true } }, { "data": { - "service.environment": null, - "service.name": "client", - "agent.name": "js-base", - "id": "client" - } - }, - { - "data": { - "service.environment": "production", - "service.name": "opbeans-node", - "agent.name": "nodejs", - "id": "opbeans-node" - } - }, - { - "data": { + "id": "opbeans-java", "service.environment": "production", - "service.name": "opbeans-go", - "agent.name": "go", - "id": "opbeans-go" + "service.name": "opbeans-java", + "agent.name": "java", + "max_score": 31.374423806075157, + "severity": "minor" } }, { "data": { + "id": "opbeans-python", "service.environment": "production", - "service.name": "opbeans-java", - "agent.name": "java", - "id": "opbeans-java" + "service.name": "opbeans-python", + "agent.name": "python", + "service.framework.name": "django", + "max_score": 92.48735, + "severity": "critical" } }, { "data": { - "destination.address": "postgres", "span.subtype": "postgresql", + "span.destination.service.resource": "postgresql", "span.type": "db", - "id": ">postgres" + "id": ">postgresql", + "label": "postgresql" } }, { "data": { + "id": "opbeans-ruby", "service.environment": "production", "service.name": "opbeans-ruby", "agent.name": "ruby", - "id": "opbeans-ruby" - } - }, - { - "data": { - "service.environment": "production", - "service.name": "opbeans-dotnet", - "agent.name": "dotnet", - "id": "opbeans-dotnet" + "service.framework.name": "Ruby on Rails" } }, { "data": { + "id": "opbeans-go", "service.environment": "production", - "service.name": "opbeans-python", - "agent.name": "python", - "id": "opbeans-python" - } - }, - { - "data": { - "destination.address": "opbeans-node", - "span.subtype": null, - "span.type": "resource", - "id": ">opbeans-node" - } - }, - { - "data": { - "service.environment": null, - "service.name": "apm-server", + "service.name": "opbeans-go", "agent.name": "go", - "id": "apm-server" - } - }, - { - "data": { - "destination.address": "172.17.0.1", - "span.subtype": "http", - "span.type": "external", - "id": ">172.17.0.1" + "service.framework.name": "gin", + "max_score": 92.40731, + "severity": "critical" } }, { "data": { + "id": "apm-server", "service.name": "apm-server", - "agent.name": "go", - "service.environment": null, - "service.framework.name": null, - "id": "apm-server" - } - }, - { - "data": { - "service.name": "opbeans-python", - "agent.name": "python", - "service.environment": null, - "service.framework.name": "django", - "id": "opbeans-python" + "agent.name": "go" } }, { "data": { - "service.name": "opbeans-ruby", - "agent.name": "ruby", - "service.environment": null, - "service.framework.name": "Ruby on Rails", - "id": "opbeans-ruby" + "span.subtype": "elasticsearch", + "span.destination.service.resource": "elasticsearch", + "span.type": "db", + "id": ">elasticsearch", + "label": "elasticsearch" } }, { "data": { + "id": "opbeans-node", + "service.environment": "production", "service.name": "opbeans-node", "agent.name": "nodejs", - "service.environment": null, - "service.framework.name": "express", - "id": "opbeans-node" + "service.framework.name": "express" } }, { "data": { - "service.name": "opbeans-go", - "agent.name": "go", - "service.environment": null, - "service.framework.name": "gin", - "id": "opbeans-go" - } - }, - { - "data": { - "service.name": "opbeans-java", - "agent.name": "java", - "service.environment": null, - "service.framework.name": null, - "id": "opbeans-java" + "id": "opbeans-dotnet", + "service.name": "opbeans-dotnet", + "agent.name": "dotnet", + "service.framework.name": "ASP.NET Core", + "max_score": 43.63972429875661, + "severity": "minor" } }, { "data": { - "service.name": "opbeans-dotnet", - "agent.name": "dotnet", - "service.environment": null, - "service.framework.name": "ASP.NET Core", - "id": "opbeans-dotnet" + "span.subtype": "redis", + "span.destination.service.resource": "redis", + "span.type": "db", + "id": ">redis", + "label": "redis" } }, { "data": { + "id": "client", "service.name": "client", - "agent.name": "js-base", - "service.environment": null, - "service.framework.name": null, - "id": "client" + "agent.name": "rum-js" } } ] diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 3bb4319d0722d..0cdc7c4eb124d 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -13,9 +13,7 @@ import { import { severity } from '../../../../common/ml_job_constants'; import { defaultIcon, iconForNode } from './icons'; -const getBorderColor = (el: cytoscape.NodeSingular) => { - const nodeSeverity = el.data('severity'); - +export const getSeverityColor = (nodeSeverity: string) => { switch (nodeSeverity) { case severity.warning: return theme.euiColorVis0; @@ -24,11 +22,20 @@ const getBorderColor = (el: cytoscape.NodeSingular) => { case severity.critical: return theme.euiColorVis9; default: - if (el.hasClass('primary') || el.selected()) { - return theme.euiColorPrimary; - } else { - return theme.euiColorMediumShade; - } + return; + } +}; + +const getBorderColor = (el: cytoscape.NodeSingular) => { + const nodeSeverity = el.data('severity'); + const severityColor = getSeverityColor(nodeSeverity); + if (severityColor) { + return severityColor; + } + if (el.hasClass('primary') || el.selected()) { + return theme.euiColorPrimary; + } else { + return theme.euiColorMediumShade; } }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index 75a247a1aae40..9065f20ae600e 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -10,7 +10,7 @@ import { getRenderedHref } from '../../../../utils/testHelpers'; import { MLJobLink } from './MLJobLink'; describe('MLJobLink', () => { - it('should produce the correct URL', async () => { + it('should produce the correct URL with serviceName', async () => { const href = await getRenderedHref( () => ( { { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location ); + expect(href).toEqual( + `/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))` + ); + }); + it('should produce the correct URL with jobId', async () => { + const href = await getRenderedHref( + () => ( + + ), + { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location + ); + expect(href).toEqual( `/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))` ); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx index 81c5d17d491c0..b085fab2b7ed6 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx @@ -8,22 +8,33 @@ import React from 'react'; import { getMlJobId } from '../../../../../common/ml_job_constants'; import { MLLink } from './MLLink'; -interface Props { +interface PropsServiceName { serviceName: string; transactionType?: string; } +interface PropsJobId { + jobId: string; +} + +type Props = (PropsServiceName | PropsJobId) & { + external?: boolean; +}; -export const MLJobLink: React.FC = ({ - serviceName, - transactionType, - children -}) => { - const jobId = getMlJobId(serviceName, transactionType); +export const MLJobLink: React.FC = props => { + const jobId = + 'jobId' in props + ? props.jobId + : getMlJobId(props.serviceName, props.transactionType); const query = { ml: { jobIds: [jobId] } }; return ( - + ); }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx index 3671a0089fd6e..7b57c193d896d 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx @@ -22,9 +22,10 @@ interface Props { query?: MlRisonData; path?: string; children?: React.ReactNode; + external?: boolean; } -export function MLLink({ children, path = '', query = {} }: Props) { +export function MLLink({ children, path = '', query = {}, external }: Props) { const { core } = useApmPluginContext(); const location = useLocation(); @@ -41,5 +42,12 @@ export function MLLink({ children, path = '', query = {} }: Props) { hash: `${path}?_g=${rison.encode(risonQuery as RisonValue)}` }); - return ; + return ( + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx index 6eff4759b2e7c..0233282bad1b5 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx @@ -34,7 +34,7 @@ const style = { export function AnnotationsPlot(props: Props) { const { plotValues, annotations } = props; - const tickValues = annotations.map(annotation => annotation.time); + const tickValues = annotations.map(annotation => annotation['@timestamp']); return ( <> @@ -46,12 +46,12 @@ export function AnnotationsPlot(props: Props) { key={annotation.id} style={{ position: 'absolute', - left: plotValues.x(annotation.time) - 8, + left: plotValues.x(annotation['@timestamp']) - 8, top: -2 }} > diff --git a/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx b/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx index afce0811b48f6..065e0b8733122 100644 --- a/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx +++ b/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx @@ -28,7 +28,7 @@ const ChartsSyncContextProvider: React.FC = ({ children }) => { callApmApi => { if (start && end && serviceName) { return callApmApi({ - pathname: '/api/apm/services/{serviceName}/annotations', + pathname: '/api/apm/services/{serviceName}/annotation/search', params: { path: { serviceName diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index f13c8853d0582..6ac2ecaae2b72 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -12,7 +12,7 @@ import { Plugin, PluginInitializerContext } from '../../../../src/core/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { PluginSetupContract as AlertingPluginPublicSetup, diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json index 5e05d3962eccb..34b67c834554d 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json @@ -1,6 +1,7 @@ { "include": [ "./plugins/apm/**/*", + "./plugins/observability/**/*", "./typings/**/*" ], "exclude": [ diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts deleted file mode 100644 index 4df02786b1fb5..0000000000000 --- a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts +++ /dev/null @@ -1,105 +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 pRetry from 'p-retry'; -import { IClusterClient, Logger } from 'src/core/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; - -export type Mappings = - | { - dynamic?: boolean | 'strict'; - properties: Record; - dynamic_templates?: any[]; - } - | { - type: string; - ignore_above?: number; - scaling_factor?: number; - ignore_malformed?: boolean; - coerce?: boolean; - fields?: Record; - }; - -export async function createOrUpdateIndex({ - index, - mappings, - esClient, - logger -}: { - index: string; - mappings: Mappings; - esClient: IClusterClient; - logger: Logger; -}) { - try { - /* - * In some cases we could be trying to create an index before ES is ready. - * When this happens, we retry creating the index with exponential backoff. - * We use retry's default formula, meaning that the first retry happens after 2s, - * the 5th after 32s, and the final attempt after around 17m. If the final attempt fails, - * the error is logged to the console. - * See https://github.com/sindresorhus/p-retry and https://github.com/tim-kos/node-retry. - */ - await pRetry(async () => { - const { callAsInternalUser } = esClient; - const indexExists = await callAsInternalUser('indices.exists', { index }); - const result = indexExists - ? await updateExistingIndex({ - index, - callAsInternalUser, - mappings - }) - : await createNewIndex({ - index, - callAsInternalUser, - mappings - }); - - if (!result.acknowledged) { - const resultError = - result && result.error && JSON.stringify(result.error); - throw new Error(resultError); - } - }); - } catch (e) { - logger.error( - `Could not create APM index: '${index}'. Error: ${e.message}.` - ); - } -} - -function createNewIndex({ - index, - callAsInternalUser, - mappings -}: { - index: string; - callAsInternalUser: CallCluster; - mappings: Mappings; -}) { - return callAsInternalUser('indices.create', { - index, - body: { - // auto_expand_replicas: Allows cluster to not have replicas for this index - settings: { 'index.auto_expand_replicas': '0-1' }, - mappings - } - }); -} - -function updateExistingIndex({ - index, - callAsInternalUser, - mappings -}: { - index: string; - callAsInternalUser: CallCluster; - mappings: Mappings; -}) { - return callAsInternalUser('indices.putMapping', { - index, - body: mappings - }); -} diff --git a/x-pack/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/plugins/apm/server/lib/helpers/es_client.ts index 45a7eca46caba..5d3cd9464af71 100644 --- a/x-pack/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/es_client.ts @@ -7,10 +7,10 @@ /* eslint-disable no-console */ import { IndexDocumentParams, - IndicesDeleteParams, SearchParams, IndicesCreateParams, - DeleteDocumentResponse + DeleteDocumentResponse, + DeleteDocumentParams } from 'elasticsearch'; import { cloneDeep, isString, merge } from 'lodash'; import { KibanaRequest } from 'src/core/server'; @@ -204,7 +204,9 @@ export function getESClient( index: (params: APMIndexDocumentParams) => { return callEs('index', params); }, - delete: (params: IndicesDeleteParams): Promise => { + delete: ( + params: Omit + ): Promise => { return callEs('delete', params); }, indicesCreate: (params: IndicesCreateParams) => { diff --git a/x-pack/plugins/apm/server/lib/helpers/range_filter.ts b/x-pack/plugins/apm/server/lib/helpers/range_filter.ts index 39581687f04f2..0647144a19c1d 100644 --- a/x-pack/plugins/apm/server/lib/helpers/range_filter.ts +++ b/x-pack/plugins/apm/server/lib/helpers/range_filter.ts @@ -4,9 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -export function rangeFilter(start: number, end: number) { +export function rangeFilter( + start: number, + end: number, + timestampField = '@timestamp' +) { return { - '@timestamp': { + [timestampField]: { gte: start, lte: end, format: 'epoch_millis' diff --git a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts index 572d73e368c7a..4af8a54139204 100644 --- a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts @@ -9,7 +9,6 @@ import { SPAN_DESTINATION_SERVICE_RESOURCE, SERVICE_NAME, SERVICE_ENVIRONMENT, - SERVICE_FRAMEWORK_NAME, AGENT_NAME, SPAN_TYPE, SPAN_SUBTYPE @@ -19,7 +18,6 @@ import { dedupeConnections } from './'; const nodejsService = { [SERVICE_NAME]: 'opbeans-node', [SERVICE_ENVIRONMENT]: 'production', - [SERVICE_FRAMEWORK_NAME]: null, [AGENT_NAME]: 'nodejs' }; @@ -32,7 +30,6 @@ const nodejsExternal = { const javaService = { [SERVICE_NAME]: 'opbeans-java', [SERVICE_ENVIRONMENT]: 'production', - [SERVICE_FRAMEWORK_NAME]: null, [AGENT_NAME]: 'java' }; diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index adb2c9b7cb084..7d5f0a75d2208 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -7,7 +7,6 @@ import { chunk } from 'lodash'; import { AGENT_NAME, SERVICE_ENVIRONMENT, - SERVICE_FRAMEWORK_NAME, SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; import { getServicesProjection } from '../../../common/projections/services'; @@ -19,6 +18,7 @@ import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; import { getTraceSampleIds } from './get_trace_sample_ids'; import { addAnomaliesToServicesData } from './ml_helpers'; import { getMlIndex } from '../../../common/ml_job_constants'; +import { rangeFilter } from '../helpers/range_filter'; export interface IEnvOptions { setup: Setup & SetupTimeRange; @@ -107,11 +107,6 @@ async function getServicesData(options: IEnvOptions) { terms: { field: AGENT_NAME } - }, - service_framework_name: { - terms: { - field: SERVICE_FRAMEWORK_NAME - } } } } @@ -129,38 +124,32 @@ async function getServicesData(options: IEnvOptions) { [SERVICE_NAME]: bucket.key as string, [AGENT_NAME]: (bucket.agent_name.buckets[0]?.key as string | undefined) || '', - [SERVICE_ENVIRONMENT]: options.environment || null, - [SERVICE_FRAMEWORK_NAME]: - (bucket.service_framework_name.buckets[0]?.key as - | string - | undefined) || null + [SERVICE_ENVIRONMENT]: options.environment || null }; }) || [] ); } function getAnomaliesData(options: IEnvOptions) { - const { client } = options.setup; + const { start, end, client } = options.setup; + const rangeQuery = { range: rangeFilter(start, end, 'timestamp') }; const params = { index: getMlIndex('*'), body: { size: 0, query: { - exists: { - field: 'bucket_span' - } + bool: { filter: [{ term: { result_type: 'record' } }, rangeQuery] } }, aggs: { jobs: { - terms: { - field: 'job_id', - size: 10 - }, + terms: { field: 'job_id', size: 10 }, aggs: { - max_score: { - max: { - field: 'anomaly_score' + top_score_hits: { + top_hits: { + sort: [{ record_score: { order: 'desc' as const } }], + _source: ['job_id', 'record_score', 'typical', 'actual'], + size: 1 } } } @@ -178,7 +167,13 @@ export type ServicesResponse = PromiseReturnType; export type ServiceMapAPIResponse = PromiseReturnType; export async function getServiceMap(options: IEnvOptions) { - const [connectionData, servicesData, anomaliesData] = await Promise.all([ + const [connectionData, servicesData, anomaliesData]: [ + // explicit types to avoid TS "excessively deep" error + ConnectionsResponse, + ServicesResponse, + AnomaliesResponse + // @ts-ignore + ] = await Promise.all([ getConnectionData(options), getServicesData(options), getAnomaliesData(options) diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts index c6680ecd6375b..c80ba8dba01ea 100644 --- a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts @@ -13,14 +13,12 @@ describe('addAnomaliesToServicesData', () => { { 'service.name': 'opbeans-ruby', 'agent.name': 'ruby', - 'service.environment': null, - 'service.framework.name': 'Ruby on Rails' + 'service.environment': null }, { 'service.name': 'opbeans-java', 'agent.name': 'java', - 'service.environment': null, - 'service.framework.name': null + 'service.environment': null } ]; @@ -30,11 +28,37 @@ describe('addAnomaliesToServicesData', () => { buckets: [ { key: 'opbeans-ruby-request-high_mean_response_time', - max_score: { value: 50 } + top_score_hits: { + hits: { + hits: [ + { + _source: { + record_score: 50, + actual: [2000], + typical: [1000], + job_id: 'opbeans-ruby-request-high_mean_response_time' + } + } + ] + } + } }, { key: 'opbeans-java-request-high_mean_response_time', - max_score: { value: 100 } + top_score_hits: { + hits: { + hits: [ + { + _source: { + record_score: 100, + actual: [9000], + typical: [3000], + job_id: 'opbeans-java-request-high_mean_response_time' + } + } + ] + } + } } ] } @@ -46,17 +70,21 @@ describe('addAnomaliesToServicesData', () => { 'service.name': 'opbeans-ruby', 'agent.name': 'ruby', 'service.environment': null, - 'service.framework.name': 'Ruby on Rails', max_score: 50, - severity: 'major' + severity: 'major', + actual_value: 2000, + typical_value: 1000, + job_id: 'opbeans-ruby-request-high_mean_response_time' }, { 'service.name': 'opbeans-java', 'agent.name': 'java', 'service.environment': null, - 'service.framework.name': null, max_score: 100, - severity: 'critical' + severity: 'critical', + actual_value: 9000, + typical_value: 3000, + job_id: 'opbeans-java-request-high_mean_response_time' } ]; diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts index 26a964bfb4dd2..9789911660bd0 100644 --- a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts +++ b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts @@ -18,29 +18,54 @@ export function addAnomaliesToServicesData( const anomaliesMap = ( anomaliesResponse.aggregations?.jobs.buckets ?? [] ).reduce<{ - [key: string]: { max_score?: number }; + [key: string]: { + max_score?: number; + actual_value?: number; + typical_value?: number; + job_id?: string; + }; }>((previousValue, currentValue) => { const key = getMlJobServiceName(currentValue.key.toString()); + const hitSource = currentValue.top_score_hits.hits.hits[0]._source as { + record_score: number; + actual: [number]; + typical: [number]; + job_id: string; + }; + const maxScore = hitSource.record_score; + const actualValue = hitSource.actual[0]; + const typicalValue = hitSource.typical[0]; + const jobId = hitSource.job_id; + + if ((previousValue[key]?.max_score ?? 0) > maxScore) { + return previousValue; + } return { ...previousValue, [key]: { - max_score: Math.max( - previousValue[key]?.max_score ?? 0, - currentValue.max_score.value ?? 0 - ) + max_score: maxScore, + actual_value: actualValue, + typical_value: typicalValue, + job_id: jobId } }; }, {}); const servicesDataWithAnomalies = servicesData.map(service => { - const score = anomaliesMap[service[SERVICE_NAME]]?.max_score; - - return { - ...service, - max_score: score, - severity: getSeverity(score) - }; + const serviceAnomalies = anomaliesMap[service[SERVICE_NAME]]; + if (serviceAnomalies) { + const maxScore = serviceAnomalies.max_score; + return { + ...service, + max_score: maxScore, + severity: getSeverity(maxScore), + actual_value: serviceAnomalies.actual_value, + typical_value: serviceAnomalies.typical_value, + job_id: serviceAnomalies.job_id + }; + } + return service; }); return servicesDataWithAnomalies; diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts new file mode 100644 index 0000000000000..e1f24bc1443f0 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts @@ -0,0 +1,111 @@ +/* + * 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 { isNumber } from 'lodash'; +import { Annotation, AnnotationType } from '../../../../common/annotations'; +import { SetupTimeRange, Setup } from '../../helpers/setup_request'; +import { ESFilter } from '../../../../typings/elasticsearch'; +import { rangeFilter } from '../../helpers/range_filter'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + SERVICE_VERSION +} from '../../../../common/elasticsearch_fieldnames'; +import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; + +export async function getDerivedServiceAnnotations({ + setup, + serviceName, + environment +}: { + serviceName: string; + environment?: string; + setup: Setup & SetupTimeRange; +}) { + const { start, end, client, indices } = setup; + + const filter: ESFilter[] = [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [SERVICE_NAME]: serviceName } } + ]; + + const environmentFilter = getEnvironmentUiFilterES(environment); + + if (environmentFilter) { + filter.push(environmentFilter); + } + + const versions = + ( + await client.search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { + bool: { + filter: filter.concat({ range: rangeFilter(start, end) }) + } + }, + aggs: { + versions: { + terms: { + field: SERVICE_VERSION + } + } + } + } + }) + ).aggregations?.versions.buckets.map(bucket => bucket.key) ?? []; + + if (versions.length <= 1) { + return []; + } + const annotations = await Promise.all( + versions.map(async version => { + const response = await client.search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { + bool: { + filter: filter.concat({ + term: { + [SERVICE_VERSION]: version + } + }) + } + }, + aggs: { + first_seen: { + min: { + field: '@timestamp' + } + } + } + } + }); + + const firstSeen = response.aggregations?.first_seen.value; + + if (!isNumber(firstSeen)) { + throw new Error( + 'First seen for version was unexpectedly undefined or null.' + ); + } + + if (firstSeen < start || firstSeen > end) { + return null; + } + + return { + type: AnnotationType.VERSION, + id: version, + '@timestamp': firstSeen, + text: version + }; + }) + ); + return annotations.filter(Boolean) as Annotation[]; +} diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts new file mode 100644 index 0000000000000..44aa554fa320a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -0,0 +1,76 @@ +/* + * 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 { APICaller } from 'kibana/server'; +import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; +import { ESSearchResponse } from '../../../../typings/elasticsearch'; +import { ScopedAnnotationsClient } from '../../../../../observability/server'; +import { Annotation, AnnotationType } from '../../../../common/annotations'; +import { Annotation as ESAnnotation } from '../../../../../observability/common/annotations'; +import { SetupTimeRange, Setup } from '../../helpers/setup_request'; +import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; + +export async function getStoredAnnotations({ + setup, + serviceName, + environment, + apiCaller, + annotationsClient +}: { + setup: Setup & SetupTimeRange; + serviceName: string; + environment?: string; + apiCaller: APICaller; + annotationsClient: ScopedAnnotationsClient; +}): Promise { + try { + const environmentFilter = getEnvironmentUiFilterES(environment); + + const response: ESSearchResponse = (await apiCaller( + 'search', + { + index: annotationsClient.index, + body: { + size: 50, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: setup.start, + lt: setup.end + } + } + }, + { term: { 'annotation.type': 'deployment' } }, + { term: { tags: 'apm' } }, + { term: { [SERVICE_NAME]: serviceName } }, + ...(environmentFilter ? [environmentFilter] : []) + ] + } + } + } + } + )) as any; + + return response.hits.hits.map(hit => { + return { + type: AnnotationType.VERSION, + id: hit._id, + '@timestamp': new Date(hit._source['@timestamp']).getTime(), + text: hit._source.message + }; + }); + } catch (error) { + // index is only created when an annotation has been indexed, + // so we should handle this error gracefully + if (error.body?.error?.type === 'index_not_found_exception') { + return []; + } + throw error; + } +} diff --git a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts index 614014ee37afc..ef70a29728e75 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { getServiceAnnotations } from '.'; +import { getDerivedServiceAnnotations } from './get_derived_service_annotations'; import { SearchParamsMock, inspectSearchParams @@ -24,7 +24,7 @@ describe('getServiceAnnotations', () => { it('returns no annotations', async () => { mock = await inspectSearchParams( setup => - getServiceAnnotations({ + getDerivedServiceAnnotations({ setup, serviceName: 'foo', environment: 'bar' @@ -34,7 +34,7 @@ describe('getServiceAnnotations', () => { } ); - expect(mock.response).toEqual({ annotations: [] }); + expect(mock.response).toEqual([]); }); }); @@ -42,7 +42,7 @@ describe('getServiceAnnotations', () => { it('returns no annotations', async () => { mock = await inspectSearchParams( setup => - getServiceAnnotations({ + getDerivedServiceAnnotations({ setup, serviceName: 'foo', environment: 'bar' @@ -52,7 +52,7 @@ describe('getServiceAnnotations', () => { } ); - expect(mock.response).toEqual({ annotations: [] }); + expect(mock.response).toEqual([]); }); }); @@ -65,7 +65,7 @@ describe('getServiceAnnotations', () => { ]; mock = await inspectSearchParams( setup => - getServiceAnnotations({ + getDerivedServiceAnnotations({ setup, serviceName: 'foo', environment: 'bar' @@ -77,22 +77,20 @@ describe('getServiceAnnotations', () => { expect(mock.spy.mock.calls.length).toBe(3); - expect(mock.response).toEqual({ - annotations: [ - { - id: '8.0.0', - text: '8.0.0', - time: 1.5281138e12, - type: 'version' - }, - { - id: '7.5.0', - text: '7.5.0', - time: 1.5281138e12, - type: 'version' - } - ] - }); + expect(mock.response).toEqual([ + { + id: '8.0.0', + text: '8.0.0', + '@timestamp': 1.5281138e12, + type: 'version' + }, + { + id: '7.5.0', + text: '7.5.0', + '@timestamp': 1.5281138e12, + type: 'version' + } + ]); }); }); }); diff --git a/x-pack/plugins/apm/server/lib/services/annotations/index.ts b/x-pack/plugins/apm/server/lib/services/annotations/index.ts index c03746ca220ee..40e7eb6535935 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/index.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/index.ts @@ -3,112 +3,51 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { isNumber } from 'lodash'; -import { Annotation, AnnotationType } from '../../../../common/annotations'; -import { ESFilter } from '../../../../typings/elasticsearch'; -import { - SERVICE_NAME, - SERVICE_ENVIRONMENT, - PROCESSOR_EVENT -} from '../../../../common/elasticsearch_fieldnames'; +import { APICaller } from 'kibana/server'; +import { ScopedAnnotationsClient } from '../../../../../observability/server'; +import { getDerivedServiceAnnotations } from './get_derived_service_annotations'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { rangeFilter } from '../../helpers/range_filter'; -import { SERVICE_VERSION } from '../../../../common/elasticsearch_fieldnames'; +import { getStoredAnnotations } from './get_stored_annotations'; export async function getServiceAnnotations({ setup, serviceName, - environment + environment, + annotationsClient, + apiCaller }: { serviceName: string; environment?: string; setup: Setup & SetupTimeRange; + annotationsClient?: ScopedAnnotationsClient; + apiCaller: APICaller; }) { - const { start, end, client, indices } = setup; - - const filter: ESFilter[] = [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - { range: rangeFilter(start, end) }, - { term: { [SERVICE_NAME]: serviceName } } - ]; - - if (environment) { - filter.push({ term: { [SERVICE_ENVIRONMENT]: environment } }); - } - - const versions = - ( - await client.search({ - index: indices['apm_oss.transactionIndices'], - body: { - size: 0, - track_total_hits: false, - query: { - bool: { - filter - } - }, - aggs: { - versions: { - terms: { - field: SERVICE_VERSION - } - } - } - } + // start fetching derived annotations (based on transactions), but don't wait on it + // it will likely be significantly slower than the stored annotations + const derivedAnnotationsPromise = getDerivedServiceAnnotations({ + setup, + serviceName, + environment + }); + + const storedAnnotations = annotationsClient + ? await getStoredAnnotations({ + setup, + serviceName, + environment, + annotationsClient, + apiCaller }) - ).aggregations?.versions.buckets.map(bucket => bucket.key) ?? []; - - if (versions.length > 1) { - const annotations = await Promise.all( - versions.map(async version => { - const response = await client.search({ - index: indices['apm_oss.transactionIndices'], - body: { - size: 0, - query: { - bool: { - filter: filter - .filter(esFilter => !Object.keys(esFilter).includes('range')) - .concat({ - term: { - [SERVICE_VERSION]: version - } - }) - } - }, - aggs: { - first_seen: { - min: { - field: '@timestamp' - } - } - }, - track_total_hits: false - } - }); + : []; - const firstSeen = response.aggregations?.first_seen.value; - - if (!isNumber(firstSeen)) { - throw new Error( - 'First seen for version was unexpectedly undefined or null.' - ); - } - - if (firstSeen < start || firstSeen > end) { - return null; - } - - return { - type: AnnotationType.VERSION, - id: version, - time: firstSeen, - text: version - }; - }) - ); - return { annotations: annotations.filter(Boolean) as Annotation[] }; + if (storedAnnotations.length) { + derivedAnnotationsPromise.catch(error => { + // handle error silently to prevent Kibana from crashing + }); + return { annotations: storedAnnotations }; } - return { annotations: [] }; + + return { + annotations: await derivedAnnotationsPromise + }; } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts index b2dc22ceb2918..356863c5f6e1e 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts @@ -5,11 +5,11 @@ */ import { IClusterClient, Logger } from 'src/core/server'; -import { APMConfig } from '../../..'; import { createOrUpdateIndex, - Mappings -} from '../../helpers/create_or_update_index'; + MappingsDefinition +} from '../../../../../observability/server'; +import { APMConfig } from '../../..'; import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; export async function createApmAgentConfigurationIndex({ @@ -22,10 +22,15 @@ export async function createApmAgentConfigurationIndex({ logger: Logger; }) { const index = getApmIndicesConfig(config).apmAgentConfigurationIndex; - return createOrUpdateIndex({ index, esClient, logger, mappings }); + return createOrUpdateIndex({ + index, + apiCaller: esClient.callAsInternalUser, + logger, + mappings + }); } -const mappings: Mappings = { +const mappings: MappingsDefinition = { dynamic: 'strict', dynamic_templates: [ { diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts index 293c01d4b61d5..be5f9f342557d 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts @@ -16,7 +16,7 @@ export async function deleteConfiguration({ const { internalClient, indices } = setup; const params = { - refresh: 'wait_for', + refresh: 'wait_for' as const, index: indices.apmAgentConfigurationIndex, id: configurationId }; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts index c05b4e113deb5..2e3e4ae7e22ed 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts @@ -6,7 +6,7 @@ import { getAllEnvironments } from './get_all_environments'; import { Setup } from '../../../helpers/setup_request'; -import { PromiseReturnType } from '../../../../../typings/common'; +import { PromiseReturnType } from '../../../../../../observability/typings/common'; import { getExistingEnvironmentsForService } from './get_existing_environments_for_service'; export type AgentConfigurationEnvironmentsAPIResponse = PromiseReturnType< diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts index 352bbe1b6a294..316a9551d992f 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts @@ -5,7 +5,7 @@ */ import { Setup } from '../../helpers/setup_request'; -import { PromiseReturnType } from '../../../../typings/common'; +import { PromiseReturnType } from '../../../../../observability/typings/common'; import { PROCESSOR_EVENT, SERVICE_NAME diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts index c44d7b41f532b..357d0e7487e1c 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PromiseReturnType } from '../../../../typings/common'; +import { PromiseReturnType } from '../../../../../observability/typings/common'; import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { convertConfigSettingsToString } from './convert_settings_to_string'; diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index f338ee058842c..5ee1f36cbac52 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -7,7 +7,7 @@ import { merge } from 'lodash'; import { Server } from 'hapi'; import { SavedObjectsClient } from 'src/core/server'; -import { PromiseReturnType } from '../../../../typings/common'; +import { PromiseReturnType } from '../../../../../observability/typings/common'; import { APM_INDICES_SAVED_OBJECT_TYPE, APM_INDICES_SAVED_OBJECT_ID diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts index 42b99b34beea7..bc0af3b0bb254 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -5,11 +5,11 @@ */ import { IClusterClient, Logger } from 'src/core/server'; -import { APMConfig } from '../../..'; import { createOrUpdateIndex, - Mappings -} from '../../helpers/create_or_update_index'; + MappingsDefinition +} from '../../../../../observability/server'; +import { APMConfig } from '../../..'; import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; export const createApmCustomLinkIndex = async ({ @@ -22,10 +22,15 @@ export const createApmCustomLinkIndex = async ({ logger: Logger; }) => { const index = getApmIndicesConfig(config).apmCustomLinkIndex; - return createOrUpdateIndex({ index, esClient, logger, mappings }); + return createOrUpdateIndex({ + index, + apiCaller: esClient.callAsInternalUser, + logger, + mappings + }); }; -const mappings: Mappings = { +const mappings: MappingsDefinition = { dynamic: 'strict', properties: { '@timestamp': { diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts index 2f3ea0940cb26..215c30b9581ff 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts @@ -16,7 +16,7 @@ export async function deleteCustomLink({ const { internalClient, indices } = setup; const params = { - refresh: 'wait_for', + refresh: 'wait_for' as const, index: indices.apmCustomLinkIndex, id: customLinkId }; diff --git a/x-pack/plugins/apm/server/lib/traces/get_trace.ts b/x-pack/plugins/apm/server/lib/traces/get_trace.ts index a1b9270e0d7b3..6d0a3e0a758e1 100644 --- a/x-pack/plugins/apm/server/lib/traces/get_trace.ts +++ b/x-pack/plugins/apm/server/lib/traces/get_trace.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PromiseReturnType } from '../../../typings/common'; +import { PromiseReturnType } from '../../../../observability/typings/common'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getTraceItems } from './get_trace_items'; diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index fb1aafc2d6c95..18f0726ae4061 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -12,7 +12,7 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { getTransactionGroupsProjection } from '../../../common/projections/transaction_groups'; import { mergeProjection } from '../../../common/projections/util/merge_projection'; -import { PromiseReturnType } from '../../../typings/common'; +import { PromiseReturnType } from '../../../../observability/typings/common'; import { SortOptions } from '../../../typings/elasticsearch/aggregations'; import { Transaction } from '../../../typings/es_schemas/ui/transaction'; import { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/transaction_groups/index.ts index 3656b32c17092..37b61b494297f 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/index.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/index.ts @@ -11,7 +11,7 @@ import { } from '../helpers/setup_request'; import { transactionGroupsFetcher, Options } from './fetcher'; import { transactionGroupsTransformer } from './transform'; -import { PromiseReturnType } from '../../../typings/common'; +import { PromiseReturnType } from '../../../../observability/typings/common'; export type TransactionGroupListAPIResponse = PromiseReturnType< typeof getTransactionGroupList diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts index 8a96a25aef50e..b36ae8961806b 100644 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts @@ -5,7 +5,7 @@ */ import { ESFilter } from '../../../../typings/elasticsearch'; -import { PromiseReturnType } from '../../../../typings/common'; +import { PromiseReturnType } from '../../../../../observability/typings/common'; import { PROCESSOR_EVENT, SERVICE_NAME, diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts index 5f211b1427259..a0b6bf40eea61 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts @@ -5,7 +5,7 @@ */ import { getMlIndex } from '../../../../../common/ml_job_constants'; -import { PromiseReturnType } from '../../../../../typings/common'; +import { PromiseReturnType } from '../../../../../../observability/typings/common'; import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; export type ESResponse = Exclude< diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts index 7a3277965ef8e..94dd4860883bd 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts @@ -7,7 +7,7 @@ import { getAnomalySeries } from '.'; import { mlAnomalyResponse } from './mock_responses/ml_anomaly_response'; import { mlBucketSpanResponse } from './mock_responses/ml_bucket_span_response'; -import { PromiseReturnType } from '../../../../../typings/common'; +import { PromiseReturnType } from '../../../../../../observability/typings/common'; import { APMConfig } from '../../../..'; describe('getAnomalySeries', () => { diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts index e33b98592da2d..8d37cfa05e951 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts @@ -13,7 +13,7 @@ import { TRANSACTION_RESULT, TRANSACTION_TYPE } from '../../../../../common/elasticsearch_fieldnames'; -import { PromiseReturnType } from '../../../../../typings/common'; +import { PromiseReturnType } from '../../../../../../observability/typings/common'; import { getBucketSize } from '../../../helpers/get_bucket_size'; import { rangeFilter } from '../../../helpers/range_filter'; import { diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/index.ts index a6a1a76e19664..45e9bb0db3c3b 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PromiseReturnType } from '../../../../typings/common'; +import { PromiseReturnType } from '../../../../../observability/typings/common'; import { Setup, SetupTimeRange, diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts index 9b22e1794f969..6d3e5d12e87cb 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PromiseReturnType } from '../../../../../typings/common'; +import { PromiseReturnType } from '../../../../../../observability/typings/common'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { bucketFetcher } from './fetcher'; diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts index 9dd29a0664329..d304a2a8a563b 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PromiseReturnType } from '../../../../typings/common'; +import { PromiseReturnType } from '../../../../../observability/typings/common'; import { Setup, SetupTimeRange, diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts index 1462cf2eeefa4..c069113e549f1 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts @@ -6,7 +6,7 @@ import { cloneDeep, sortByOrder } from 'lodash'; import { UIFilters } from '../../../../typings/ui_filters'; import { Projection } from '../../../../common/projections/typings'; -import { PromiseReturnType } from '../../../../typings/common'; +import { PromiseReturnType } from '../../../../../observability/typings/common'; import { getLocalFilterQuery } from './get_local_filter_query'; import { Setup } from '../../helpers/setup_request'; import { localUIFilters, LocalUIFilterName } from './config'; diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 29ab618cbdd0a..b17bffea812fc 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -12,6 +12,7 @@ import { } from 'src/core/server'; import { Observable, combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; +import { ObservabilityPluginSetup } from '../../observability/server'; import { SecurityPluginSetup } from '../../security/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { TaskManagerSetupContract } from '../../task_manager/server'; @@ -57,6 +58,7 @@ export class APMPlugin implements Plugin { taskManager?: TaskManagerSetupContract; alerting?: AlertingPlugin['setup']; actions?: ActionsPlugin['setup']; + observability?: ObservabilityPluginSetup; features: FeaturesPluginSetup; security?: SecurityPluginSetup; } @@ -114,6 +116,7 @@ export class APMPlugin implements Plugin { config$: mergedConfig$, logger: this.logger!, plugins: { + observability: plugins.observability, security: plugins.security } }); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index 9b611a0bbd6bc..53c3630d5a743 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -136,13 +136,13 @@ export function createApi() { request, context: { ...context, + plugins, // Only return values for parameters that have runtime types, // but always include query as _debug is always set even if // it's not defined in the route. params: pick(parsedParams, ...Object.keys(params), 'query'), config, - logger, - plugins + logger } }); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 7964d8b0268e8..4fd740c4e81c1 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -19,7 +19,8 @@ import { serviceTransactionTypesRoute, servicesRoute, serviceNodeMetadataRoute, - serviceAnnotationsRoute + serviceAnnotationsRoute, + serviceAnnotationsCreateRoute } from './services'; import { agentConfigurationRoute, @@ -87,6 +88,7 @@ const createApmApi = () => { .add(servicesRoute) .add(serviceNodeMetadataRoute) .add(serviceAnnotationsRoute) + .add(serviceAnnotationsCreateRoute) // Agent configuration .add(getSingleAgentConfigurationRoute) diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 1c6561ee24c93..474ab1c6082a4 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,6 +5,9 @@ */ import * as t from 'io-ts'; +import Boom from 'boom'; +import { unique } from 'lodash'; +import { ScopedAnnotationsClient } from '../../../observability/server'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; @@ -13,6 +16,7 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; +import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; export const servicesRoute = createRoute(core => ({ path: '/api/apm/services', @@ -74,7 +78,7 @@ export const serviceNodeMetadataRoute = createRoute(() => ({ })); export const serviceAnnotationsRoute = createRoute(() => ({ - path: '/api/apm/services/{serviceName}/annotations', + path: '/api/apm/services/{serviceName}/annotation/search', params: { path: t.type({ serviceName: t.string @@ -91,10 +95,74 @@ export const serviceAnnotationsRoute = createRoute(() => ({ const { serviceName } = context.params.path; const { environment } = context.params.query; + let annotationsClient: ScopedAnnotationsClient | undefined; + + if (context.plugins.observability) { + annotationsClient = await context.plugins.observability.getScopedAnnotationsClient( + request + ); + } + return getServiceAnnotations({ setup, serviceName, - environment + environment, + annotationsClient, + apiCaller: context.core.elasticsearch.dataClient.callAsCurrentUser + }); + } +})); + +export const serviceAnnotationsCreateRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/annotation', + method: 'POST', + options: { + tags: ['access:apm', 'access:apm_write'] + }, + params: { + path: t.type({ + serviceName: t.string + }), + body: t.intersection([ + t.type({ + '@timestamp': dateAsStringRt, + service: t.intersection([ + t.type({ + version: t.string + }), + t.partial({ + environment: t.string + }) + ]) + }), + t.partial({ + message: t.string, + tags: t.array(t.string) + }) + ]) + }, + handler: async ({ request, context }) => { + const annotationsClient = await context.plugins.observability?.getScopedAnnotationsClient( + request + ); + + if (!annotationsClient) { + throw Boom.notFound(); + } + + const { body, path } = context.params; + + return annotationsClient.create({ + message: body.service.version, + ...body, + annotation: { + type: 'deployment' + }, + service: { + ...body.service, + name: path.serviceName + }, + tags: unique(['apm'].concat(body.tags ?? [])) }); } })); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index e049255eb8ec8..fe6195605fb2a 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -14,6 +14,7 @@ import { import { PickByValue, Optional } from 'utility-types'; import { Observable } from 'rxjs'; import { Server } from 'hapi'; +import { ObservabilityPluginSetup } from '../../../observability/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FetchOptions } from '../../public/services/rest/callApi'; import { SecurityPluginSetup } from '../../../security/public'; @@ -64,6 +65,7 @@ export type APMRequestHandlerContext< config: APMConfig; logger: Logger; plugins: { + observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; }; }; @@ -110,6 +112,7 @@ export interface ServerAPI { config$: Observable; logger: Logger; plugins: { + observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; }; } diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index d1bcae549805e..586c2b0c2a259 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -16,6 +16,7 @@ export { ActionTypeExecutorResult } from '../../../../actions/server/types'; const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ + connector_id: rt.string, description: rt.string, status: StatusRt, tags: rt.array(rt.string), diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index d92af587d0e92..7d20011a428cf 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -8,10 +8,12 @@ import * as rt from 'io-ts'; import { ActionResult } from '../../../../actions/common'; import { UserRT } from '../user'; +import { JiraFieldsRT } from '../connectors/jira'; +import { ServiceNowFieldsRT } from '../connectors/servicenow'; /* * This types below are related to the service now configuration - * mapping between our case and service-now + * mapping between our case and [service-now, jira] * */ @@ -27,12 +29,7 @@ const CaseFieldRT = rt.union([ rt.literal('comments'), ]); -const ThirdPartyFieldRT = rt.union([ - rt.literal('comments'), - rt.literal('description'), - rt.literal('not_mapped'), - rt.literal('short_description'), -]); +const ThirdPartyFieldRT = rt.union([JiraFieldsRT, ServiceNowFieldsRT, rt.literal('not_mapped')]); export const CasesConfigurationMapsRT = rt.type({ source: CaseFieldRT, diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 2b70a698a5152..0bed0fd8fc57d 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -14,6 +14,7 @@ import { UserRT } from '../user'; const UserActionFieldRt = rt.array( rt.union([ rt.literal('comment'), + rt.literal('connector_id'), rt.literal('description'), rt.literal('pushed'), rt.literal('tags'), diff --git a/x-pack/test/reporting/services/index.js b/x-pack/plugins/case/common/api/connectors/index.ts similarity index 81% rename from x-pack/test/reporting/services/index.js rename to x-pack/plugins/case/common/api/connectors/index.ts index d181e24442d2d..c1fc284c938b7 100644 --- a/x-pack/test/reporting/services/index.js +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ReportingAPIProvider } from './reporting_api'; +export * from './jira'; +export * from './servicenow'; diff --git a/x-pack/plugins/case/common/api/connectors/jira.ts b/x-pack/plugins/case/common/api/connectors/jira.ts new file mode 100644 index 0000000000000..4e4674318ddd8 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/jira.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const JiraFieldsRT = rt.union([ + rt.literal('summary'), + rt.literal('description'), + rt.literal('comments'), +]); + +export type JiraFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow.ts b/x-pack/plugins/case/common/api/connectors/servicenow.ts new file mode 100644 index 0000000000000..fc124bfd46094 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/servicenow.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const ServiceNowFieldsRT = rt.union([ + rt.literal('short_description'), + rt.literal('description'), + rt.literal('comments'), +]); + +export type ServiceNowFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 75e793a80272f..135eeecdd491a 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -18,6 +18,7 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -46,6 +47,7 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -74,6 +76,7 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, + connector_id: '123', created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -106,6 +109,7 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + connector_id: '123', created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -130,6 +134,35 @@ export const mockCases: Array> = [ }, ]; +export const mockCaseNoConnectorId: SavedObject> = { + type: 'cases', + id: 'mock-no-connector_id', + attributes: { + closed_at: null, + closed_by: null, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2019-11-25T21:54:48.952Z', + version: 'WzAsMV0=', +}; + export const mockCasesErrorTriggerData = [ { id: 'valid-id', diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index dd9b124ff1b79..90661a7d3897d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -16,8 +16,14 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCaseSavedObject } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { getConnectorId } from '../helpers'; -export function initPatchCommentApi({ caseService, router, userActionService }: RouteDeps) { +export function initPatchCommentApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.patch( { path: CASE_COMMENTS_URL, @@ -64,7 +70,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDate = new Date().toISOString(); - const [updatedComment, updatedCase] = await Promise.all([ + const [updatedComment, updatedCase, myCaseConfigure] = await Promise.all([ caseService.patchComment({ client, commentId: query.id, @@ -84,6 +90,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: }, version: myCase.version, }), + caseConfigureService.find({ client }), ]); const totalCommentsFindByCases = await caseService.getAllCaseComments({ @@ -95,7 +102,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: perPage: 1, }, }); - + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); const [comments] = await Promise.all([ caseService.getAllCaseComments({ client, @@ -125,16 +132,17 @@ export function initPatchCommentApi({ caseService, router, userActionService }: return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase.attributes }, version: updatedCase.version ?? myCase.version, references: myCase.references, }, - comments.saved_objects - ) + comments: comments.saved_objects, + caseConfigureConnectorId, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index a296d9815f251..486f709b1e7ed 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -16,8 +16,14 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { escapeHatch, transformNewComment, wrapError, flattenCaseSavedObject } from '../../utils'; import { RouteDeps } from '../../types'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { getConnectorId } from '../helpers'; -export function initPostCommentApi({ caseService, router, userActionService }: RouteDeps) { +export function initPostCommentApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.post( { path: CASE_COMMENTS_URL, @@ -45,7 +51,7 @@ export function initPostCommentApi({ caseService, router, userActionService }: R const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); - const [newComment, updatedCase] = await Promise.all([ + const [newComment, updatedCase, myCaseConfigure] = await Promise.all([ caseService.postNewComment({ client, attributes: transformNewComment({ @@ -72,8 +78,10 @@ export function initPostCommentApi({ caseService, router, userActionService }: R }, version: myCase.version, }), + caseConfigureService.find({ client }), ]); + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); const totalCommentsFindByCases = await caseService.getAllCaseComments({ client, caseId, @@ -112,16 +120,17 @@ export function initPostCommentApi({ caseService, router, userActionService }: R return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase.attributes }, version: updatedCase.version ?? myCase.version, references: myCase.references, }, - comments.saved_objects - ) + comments: comments.saved_objects, + caseConfigureConnectorId, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 03bec1fe72d39..5f83e8d6f94f5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -9,7 +9,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -export function initGetCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { +export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps) { router.get( { path: CASE_CONFIGURE_URL, diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index 7af1cee494457..9adb1eeb1bca0 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -15,8 +15,9 @@ import { } from '../__fixtures__'; import { initFindCasesApi } from './find_cases'; import { CASES_URL } from '../../../../common/constants'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -describe('GET all cases', () => { +describe('FIND all cases', () => { let routeHandler: RequestHandler; beforeAll(async () => { routeHandler = await createRoute(initFindCasesApi, 'get'); @@ -37,4 +38,53 @@ describe('GET all cases', () => { expect(response.status).toEqual(200); expect(response.payload.cases).toHaveLength(4); }); + it(`has proper connector id on cases with configured id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[2].connector_id).toEqual('123'); + }); + it(`adds 'none' connector id to cases without when 3rd party unconfigured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[0].connector_id).toEqual('none'); + }); + it(`adds default connector id to cases without when 3rd party configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[0].connector_id).toEqual('123'); + }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 40fc0301b058a..cbe26ebe2f642 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -16,6 +16,7 @@ import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => filters?.filter(i => i !== '').join(` ${operator} `); @@ -39,7 +40,7 @@ const buildFilter = ( : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` : ''; -export function initFindCasesApi({ caseService, router }: RouteDeps) { +export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( { path: `${CASES_URL}/_find`, @@ -94,12 +95,12 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { filter: getStatusFilter('closed', myFilters), }, }; - const [cases, openCases, closesCases] = await Promise.all([ + const [cases, openCases, closesCases, myCaseConfigure] = await Promise.all([ caseService.findCases(args), caseService.findCases(argsOpenCases), caseService.findCases(argsClosedCases), + caseConfigureService.find({ client }), ]); - const totalCommentsFindByCases = await Promise.all( cases.saved_objects.map(c => caseService.getAllCaseComments({ @@ -135,7 +136,8 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { cases, openCases.total ?? 0, closesCases.total ?? 0, - totalCommentsByCases + totalCommentsByCases, + getConnectorId(myCaseConfigure) ) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts index a8c12d4734b53..6c0b5bdff418d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts @@ -19,6 +19,7 @@ import { import { flattenCaseSavedObject } from '../utils'; import { initGetCaseApi } from './get_case'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('GET case', () => { let routeHandler: RequestHandler; @@ -44,14 +45,11 @@ describe('GET case', () => { ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - + const savedObject = (mockCases.find(s => s.id === 'mock-id-1') as unknown) as SavedObject< + CaseAttributes + >; expect(response.status).toEqual(200); - expect(response.payload).toEqual( - flattenCaseSavedObject( - (mockCases.find(s => s.id === 'mock-id-1') as unknown) as SavedObject, - [] - ) - ); + expect(response.payload).toEqual(flattenCaseSavedObject({ savedObject })); expect(response.payload.comments).toEqual([]); }); it(`returns an error when thrown from getCase`, async () => { @@ -123,4 +121,75 @@ describe('GET case', () => { expect(response.status).toEqual(400); }); + it(`case w/o connector_id - returns the case with connector id when 3rd party unconfigured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-no-connector_id', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('none'); + }); + it(`case w/o connector_id - returns the case with connector id when 3rd party configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-no-connector_id', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); + }); + it(`case w/ connector_id - returns the case with connector id when case already has connectorId`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-id-3', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); + }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 1e836d38c285c..57b472d3889cc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -10,8 +10,9 @@ import { CaseResponseRt } from '../../../../common/api'; import { RouteDeps } from '../types'; import { flattenCaseSavedObject, wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; -export function initGetCaseApi({ caseService, router }: RouteDeps) { +export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { router.get( { path: CASE_DETAILS_URL, @@ -29,13 +30,25 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { const client = context.core.savedObjects.client; const includeComments = JSON.parse(request.query.includeComments); - const theCase = await caseService.getCase({ - client, - caseId: request.params.case_id, - }); + const [theCase, myCaseConfigure] = await Promise.all([ + caseService.getCase({ + client, + caseId: request.params.case_id, + }), + caseConfigureService.find({ client }), + ]); + + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); if (!includeComments) { - return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, [])) }); + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + caseConfigureConnectorId, + }) + ), + }); } const theComments = await caseService.getAllCaseComments({ @@ -48,7 +61,14 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }); return response.ok({ - body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, theComments.saved_objects)), + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + caseConfigureConnectorId, + }) + ), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 46c2209d79f7d..b02bc0b4e10a2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -6,7 +6,8 @@ import { get } from 'lodash'; -import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { CaseAttributes, CasePatchRequest, CasesConfigureAttributes } from '../../../../common/api'; interface CompareArrays { addedItems: string[]; @@ -75,8 +76,20 @@ export const getCaseToUpdate = ( ...acc, [key]: value, }; + } else if (currentValue == null && key === 'connector_id' && value !== currentValue) { + return { + ...acc, + [key]: value, + }; } return acc; }, { id: queryCase.id, version: queryCase.version } ); + +export const getConnectorId = ( + caseConfigure: SavedObjectsFindResponse +): string => + caseConfigure.saved_objects.length > 0 + ? caseConfigure.saved_objects[0].attributes.connector_id + : 'none'; diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index ac1e67cec52bd..b5100907f246a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -15,6 +15,7 @@ import { mockCaseComments, } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('PATCH cases', () => { let routeHandler: RequestHandler; @@ -53,6 +54,7 @@ describe('PATCH cases', () => { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comments: [], + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', @@ -86,6 +88,7 @@ describe('PATCH cases', () => { const theContext = createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, }) ); @@ -96,6 +99,7 @@ describe('PATCH cases', () => { closed_at: null, closed_by: null, comments: [], + connector_id: '123', created_at: '2019-11-25T22:32:17.947Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'Oh no, a bad meanie going LOLBins all over the place!', @@ -111,6 +115,56 @@ describe('PATCH cases', () => { }, ]); }); + it(`Patches a case without a connector_id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-no-connector_id', + status: 'closed', + version: 'WzAsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload[0].connector_id).toEqual('none'); + }); + it(`Patches a case with a connector_id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-3', + status: 'closed', + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload[0].connector_id).toEqual('123'); + }); it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 57f9fc20dbf34..6d2a5f943cea9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -18,11 +18,16 @@ import { } from '../../../../common/api'; import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; -import { getCaseToUpdate } from './helpers'; +import { getCaseToUpdate, getConnectorId } from './helpers'; import { buildCaseUserActions } from '../../../services/user_actions/helpers'; import { CASES_URL } from '../../../../common/constants'; -export function initPatchCasesApi({ caseService, router, userActionService }: RouteDeps) { +export function initPatchCasesApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.patch( { path: CASES_URL, @@ -37,10 +42,16 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro excess(CasesPatchRequestRt).decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCases = await caseService.getCases({ - client, - caseIds: query.cases.map(q => q.id), - }); + + const [myCases, myCaseConfigure] = await Promise.all([ + caseService.getCases({ + client, + caseIds: query.cases.map(q => q.id), + }), + caseConfigureService.find({ client }), + ]); + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); @@ -114,11 +125,14 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro .map(myCase => { const updatedCase = updatedCases.saved_objects.find(c => c.id === myCase.id); return flattenCaseSavedObject({ - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - version: updatedCase?.version ?? myCase.version, + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + version: updatedCase?.version ?? myCase.version, + }, + caseConfigureConnectorId, }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 0bbceb5214046..b545eb8b7fb08 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -15,6 +15,7 @@ import { } from '../__fixtures__'; import { initPostCaseApi } from './post_case'; import { CASES_URL } from '../../../../common/constants'; +import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; describe('POST cases', () => { let routeHandler: RequestHandler; @@ -25,7 +26,7 @@ describe('POST cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); - it(`Posts a new case`, async () => { + it(`Posts a new case, no connector configured`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASES_URL, method: 'post', @@ -46,6 +47,29 @@ describe('POST cases', () => { expect(response.status).toEqual(200); expect(response.payload.id).toEqual('mock-it'); expect(response.payload.created_by.username).toEqual('awesome'); + expect(response.payload.connector_id).toEqual('none'); + }); + it(`Posts a new case, connector configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASES_URL, + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + tags: ['defacement'], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); }); it(`Error if you passing status for a new case`, async () => { @@ -106,6 +130,7 @@ describe('POST cases', () => { const theContext = createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, }) ); @@ -115,6 +140,7 @@ describe('POST cases', () => { closed_at: null, closed_by: null, comments: [], + connector_id: '123', created_at: '2019-11-25T21:54:48.952Z', created_by: { email: null, diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 059a8b1affd54..05574698edd44 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -15,8 +15,14 @@ import { CasePostRequestRt, throwErrors, excess, CaseResponseRt } from '../../.. import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; -export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { +export function initPostCaseApi({ + caseService, + caseConfigureService, + router, + userActionService, +}: RouteDeps) { router.post( { path: CASES_URL, @@ -34,6 +40,8 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); + const myCaseConfigure = await caseConfigureService.find({ client }); + const connectorId = getConnectorId(myCaseConfigure); const newCase = await caseService.postNewCase({ client, attributes: transformNewCase({ @@ -42,6 +50,7 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout username, full_name, email, + connectorId, }), }); @@ -59,7 +68,13 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout ], }); - return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: newCase, + }) + ), + }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 94ebe24c3d2ae..c6638d292a197 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -16,6 +16,7 @@ import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../.. import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; export function initPushCaseUserActionApi({ caseConfigureService, @@ -83,6 +84,11 @@ export function initPushCaseUserActionApi({ ...query, }; + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + // old case may not have new attribute connector_id, so we default to the configured system + const updateConnectorId = + myCase.attributes.connector_id == null ? { connector_id: caseConfigureConnectorId } : {}; + const [updatedCase, updatedComments] = await Promise.all([ caseService.patchCase({ client, @@ -98,6 +104,7 @@ export function initPushCaseUserActionApi({ external_service: externalService, updated_at: pushedDate, updated_by: { username, full_name, email }, + ...updateConnectorId, }, version: myCase.version, }), @@ -143,14 +150,14 @@ export function initPushCaseUserActionApi({ ]); return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase?.attributes }, references: myCase.references, }, - comments.saved_objects.map(origComment => { + comments: comments.saved_objects.map(origComment => { const updatedComment = updatedComments.saved_objects.find( c => c.id === origComment.id ); @@ -164,8 +171,8 @@ export function initPushCaseUserActionApi({ version: updatedComment?.version ?? origComment.version, references: origComment?.references ?? [], }; - }) - ) + }), + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index a22f4db30bf8d..81156b98bab83 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -18,13 +18,18 @@ import { } from './utils'; import { newCase } from './__mocks__/request_responses'; import { isBoom, boomify } from 'boom'; -import { mockCases, mockCaseComments } from './__fixtures__/mock_saved_objects'; +import { + mockCases, + mockCaseComments, + mockCaseNoConnectorId, +} from './__fixtures__/mock_saved_objects'; describe('Utils', () => { describe('transformNewCase', () => { it('transform correctly', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', full_name: 'Elastic', @@ -37,6 +42,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, external_service: null, @@ -49,6 +55,7 @@ describe('Utils', () => { it('transform correctly without optional fields', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', }; @@ -58,6 +65,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: undefined, full_name: undefined, username: undefined }, external_service: null, @@ -70,6 +78,7 @@ describe('Utils', () => { it('transform correctly with optional fields as null', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', email: null, full_name: null, @@ -82,6 +91,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: null, full_name: null, username: null }, external_service: null, @@ -204,7 +214,7 @@ describe('Utils', () => { describe('transformCases', () => { it('transforms correctly', () => { - const totalCommentsByCase = [ + const extraCaseData = [ { caseId: mockCases[0].id, totalComments: 2 }, { caseId: mockCases[1].id, totalComments: 2 }, { caseId: mockCases[2].id, totalComments: 2 }, @@ -215,13 +225,14 @@ describe('Utils', () => { { saved_objects: mockCases, total: mockCases.length, per_page: 10, page: 1 }, 2, 2, - totalCommentsByCase + extraCaseData, + '123' ); expect(res).toEqual({ page: 1, per_page: 10, total: mockCases.length, - cases: flattenCaseSavedObjects(mockCases, totalCommentsByCase), + cases: flattenCaseSavedObjects(mockCases, extraCaseData, '123'), count_open_cases: 2, count_closed_cases: 2, }); @@ -230,14 +241,15 @@ describe('Utils', () => { describe('flattenCaseSavedObjects', () => { it('flattens correctly', () => { - const totalCommentsByCase = [{ caseId: mockCases[0].id, totalComments: 2 }]; + const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; - const res = flattenCaseSavedObjects([mockCases[0]], totalCommentsByCase); + const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123'); expect(res).toEqual([ { id: 'mock-id-1', closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -262,16 +274,94 @@ describe('Utils', () => { ]); }); - it('it handles total comments correctly', () => { - const totalCommentsByCase = [{ caseId: 'not-exist', totalComments: 2 }]; - - const res = flattenCaseSavedObjects([mockCases[0]], totalCommentsByCase); + it('it handles total comments correctly when caseId is not in extraCaseData', () => { + const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; + const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123'); expect(res).toEqual([ { id: 'mock-id-1', closed_at: null, closed_by: null, + connector_id: 'none', + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 0, + version: 'WzAsMV0=', + }, + ]); + }); + it('inserts missing connectorId', () => { + const extraCaseData = [ + { + caseId: mockCaseNoConnectorId.id, + totalComment: 0, + }, + ]; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData, '123'); + expect(res).toEqual([ + { + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: '123', + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 0, + version: 'WzAsMV0=', + }, + ]); + }); + it('inserts missing connectorId (none)', () => { + const extraCaseData = [ + { + caseId: mockCaseNoConnectorId.id, + totalComment: 0, + }, + ]; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); + expect(res).toEqual([ + { + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -300,7 +390,7 @@ describe('Utils', () => { describe('flattenCaseSavedObject', () => { it('flattens correctly', () => { const myCase = { ...mockCases[0] }; - const res = flattenCaseSavedObject(myCase, [], 2); + const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: myCase.version, @@ -313,7 +403,7 @@ describe('Utils', () => { it('flattens correctly without version', () => { const myCase = { ...mockCases[0] }; myCase.version = undefined; - const res = flattenCaseSavedObject(myCase, [], 2); + const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: '0', @@ -326,7 +416,7 @@ describe('Utils', () => { it('flattens correctly with comments', () => { const myCase = { ...mockCases[0] }; const comments = [{ ...mockCaseComments[0] }]; - const res = flattenCaseSavedObject(myCase, comments, 2); + const res = flattenCaseSavedObject({ savedObject: myCase, comments, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: myCase.version, @@ -335,6 +425,76 @@ describe('Utils', () => { ...myCase.attributes, }); }); + it('inserts missing connectorId', () => { + const extraCaseData = { + totalComment: 2, + caseConfigureConnectorId: '123', + }; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData }); + expect(res).toEqual({ + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: '123', + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 2, + version: 'WzAsMV0=', + }); + }); + it('inserts missing connectorId (none)', () => { + const extraCaseData = { + totalComment: 2, + caseConfigureConnectorId: 'none', + }; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData }); + expect(res).toEqual({ + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: 'none', + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 2, + version: 'WzAsMV0=', + }); + }); }); describe('transformComments', () => { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index a3df0fc93d2ac..b65205734d569 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -26,12 +26,14 @@ import { import { SortFieldCase, TotalCommentByCase } from './types'; export const transformNewCase = ({ + connectorId, createdDate, email, full_name, newCase, username, }: { + connectorId: string; createdDate: string; email?: string | null; full_name?: string | null; @@ -41,6 +43,7 @@ export const transformNewCase = ({ ...newCase, closed_at: null, closed_by: null, + connector_id: connectorId, created_at: createdDate, created_by: { email, full_name, username }, external_service: null, @@ -86,40 +89,50 @@ export const transformCases = ( cases: SavedObjectsFindResponse, countOpenCases: number, countClosedCases: number, - totalCommentByCase: TotalCommentByCase[] + totalCommentByCase: TotalCommentByCase[], + caseConfigureConnectorId: string = 'none' ): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), + cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase, caseConfigureConnectorId), count_open_cases: countOpenCases, count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( savedObjects: SavedObjectsFindResponse['saved_objects'], - totalCommentByCase: TotalCommentByCase[] + totalCommentByCase: TotalCommentByCase[], + caseConfigureConnectorId: string = 'none' ): CaseResponse[] => savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { return [ ...acc, - flattenCaseSavedObject( + flattenCaseSavedObject({ savedObject, - [], - totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0 - ), + totalComment: + totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0, + caseConfigureConnectorId, + }), ]; }, []); -export const flattenCaseSavedObject = ( - savedObject: SavedObject, - comments: Array> = [], - totalComment: number = 0 -): CaseResponse => ({ +export const flattenCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = 0, + caseConfigureConnectorId = 'none', +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; + caseConfigureConnectorId?: string; +}): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, + connector_id: savedObject.attributes.connector_id ?? caseConfigureConnectorId, ...savedObject.attributes, }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index cc2b1e74b38c4..26ed6ab7cc0bc 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -49,6 +49,9 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, + connector_id: { + type: 'keyword', + }, external_service: { properties: { pushed_at: { diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index e89700419b19d..af50b3b394325 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -119,6 +119,7 @@ export const buildCaseUserActionItem = ({ const userActionFieldsAllowed: UserActionField = [ 'comment', + 'connector_id', 'description', 'tags', 'title', diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/fixtures/follower_index.ts b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/fixtures/follower_index.ts index ff051d470531b..ce40a0e46b0a0 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/fixtures/follower_index.ts +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/fixtures/follower_index.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import Chance from 'chance'; import { getRandomString } from '../../../../../../test_utils'; import { FollowerIndex } from '../../../../common/types'; -const Chance = require('chance'); // eslint-disable-line import/no-extraneous-dependencies, @typescript-eslint/no-var-requires const chance = new Chance(); interface FollowerIndexMock { diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx index a1cd003949a22..32c3f73ced287 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx @@ -85,40 +85,66 @@ export const GraphControls = styled(
-
- - -
-
- - diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 3201e83164dba..0840b990ea315 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -351,7 +351,9 @@ export const ProcessEventDot = styled( }} > - {eventModel.eventName(event)} + + {eventModel.eventName(event)} +
{magFactorX >= 2 && ( diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index 83cc9e1eb7cc8..2405f05768a2f 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -11,7 +11,7 @@ import { RecursiveReadonly, } from '../../../../src/core/server'; import { Capabilities as UICapabilities } from '../../../../src/core/server'; -import { deepFreeze } from '../../../../src/core/utils'; +import { deepFreeze } from '../../../../src/core/server'; import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; import { PluginSetupContract as TimelionSetupContract } from '../../../../src/plugins/vis_type_timelion/server'; import { FeatureRegistry } from './feature_registry'; diff --git a/x-pack/plugins/file_upload/public/util/geo_json_clean_and_validate.test.js b/x-pack/plugins/file_upload/public/util/geo_json_clean_and_validate.test.js index 90538ae652c08..872df0cddca3c 100644 --- a/x-pack/plugins/file_upload/public/util/geo_json_clean_and_validate.test.js +++ b/x-pack/plugins/file_upload/public/util/geo_json_clean_and_validate.test.js @@ -5,7 +5,7 @@ */ import { cleanGeometry, geoJsonCleanAndValidate } from './geo_json_clean_and_validate'; -const jsts = require('jsts'); +import * as jsts from 'jsts'; describe('geo_json_clean_and_validate', () => { const reader = new jsts.io.GeoJSONReader(); diff --git a/x-pack/plugins/file_upload/public/util/pattern_reader.js b/x-pack/plugins/file_upload/public/util/pattern_reader.js index 78f1c4f217519..152e0f7e54580 100644 --- a/x-pack/plugins/file_upload/public/util/pattern_reader.js +++ b/x-pack/plugins/file_upload/public/util/pattern_reader.js @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -const oboe = require('oboe'); +import oboe from 'oboe'; export class PatternReader { constructor({ onFeatureDetect, onStreamComplete }) { diff --git a/x-pack/plugins/graph/common/check_license.ts b/x-pack/plugins/graph/common/check_license.ts index f9a663f35ed47..38d6c272f2784 100644 --- a/x-pack/plugins/graph/common/check_license.ts +++ b/x-pack/plugins/graph/common/check_license.ts @@ -6,7 +6,12 @@ import { i18n } from '@kbn/i18n'; import { ILicense } from '../../licensing/common/types'; -import { assertNever } from '../../../../src/core/utils'; + +// Can be used in switch statements to ensure we perform exhaustive checks, see +// https://www.typescriptlang.org/docs/handbook/advanced-types.html#exhaustiveness-checking +export function assertNever(x: never): never { + throw new Error(`Unexpected object: ${x}`); +} export interface GraphLicenseInformation { showAppLink: boolean; diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.js b/x-pack/plugins/graph/public/angular/graph_client_workspace.js index a7d98a42404ec..bcd31716b6d64 100644 --- a/x-pack/plugins/graph/public/angular/graph_client_workspace.js +++ b/x-pack/plugins/graph/public/angular/graph_client_workspace.js @@ -7,1576 +7,1569 @@ import $ from 'jquery'; // Kibana wrapper -const d3 = require('d3'); - -module.exports = (function() { - // Pluggable function to handle the comms with a server. Default impl here is - // for use outside of Kibana server with direct access to elasticsearch - let graphExplorer = function(indexName, typeName, request, responseHandler) { - const dataForServer = JSON.stringify(request); - $.ajax({ - type: 'POST', - url: 'http://localhost:9200/' + indexName + '/_graph/explore', - dataType: 'json', - contentType: 'application/json;charset=utf-8', - async: true, - data: dataForServer, - success: function(data) { - responseHandler(data); - }, - }); +import d3 from 'd3'; + +// Pluggable function to handle the comms with a server. Default impl here is +// for use outside of Kibana server with direct access to elasticsearch +let graphExplorer = function(indexName, typeName, request, responseHandler) { + const dataForServer = JSON.stringify(request); + $.ajax({ + type: 'POST', + url: 'http://localhost:9200/' + indexName + '/_graph/explore', + dataType: 'json', + contentType: 'application/json;charset=utf-8', + async: true, + data: dataForServer, + success: function(data) { + responseHandler(data); + }, + }); +}; +let searcher = function(indexName, request, responseHandler) { + const dataForServer = JSON.stringify(request); + $.ajax({ + type: 'POST', + url: 'http://localhost:9200/' + indexName + '/_search?rest_total_hits_as_int=true', + dataType: 'json', + contentType: 'application/json;charset=utf-8', //Not sure why this was necessary - worked without elsewhere + async: true, + data: dataForServer, + success: function(data) { + responseHandler(data); + }, + }); +}; + +// ====== Undo operations ============= + +function AddNodeOperation(node, owner) { + const self = this; + const vm = owner; + self.node = node; + self.undo = function() { + vm.arrRemove(vm.nodes, self.node); + vm.arrRemove(vm.selectedNodes, self.node); + self.node.isSelected = false; + + delete vm.nodesMap[self.node.id]; }; - let searcher = function(indexName, request, responseHandler) { - const dataForServer = JSON.stringify(request); - $.ajax({ - type: 'POST', - url: 'http://localhost:9200/' + indexName + '/_search?rest_total_hits_as_int=true', - dataType: 'json', - contentType: 'application/json;charset=utf-8', //Not sure why this was necessary - worked without elsewhere - async: true, - data: dataForServer, - success: function(data) { - responseHandler(data); - }, - }); + self.redo = function() { + vm.nodes.push(self.node); + vm.nodesMap[self.node.id] = self.node; }; - - // ====== Undo operations ============= - - function AddNodeOperation(node, owner) { - const self = this; - const vm = owner; - self.node = node; - self.undo = function() { - vm.arrRemove(vm.nodes, self.node); - vm.arrRemove(vm.selectedNodes, self.node); - self.node.isSelected = false; - - delete vm.nodesMap[self.node.id]; - }; - self.redo = function() { - vm.nodes.push(self.node); - vm.nodesMap[self.node.id] = self.node; - }; - } - - function AddEdgeOperation(edge, owner) { - const self = this; - const vm = owner; - self.edge = edge; - self.undo = function() { - vm.arrRemove(vm.edges, self.edge); - delete vm.edgesMap[self.edge.id]; - }; - self.redo = function() { - vm.edges.push(self.edge); - vm.edgesMap[self.edge.id] = self.edge; - }; - } - - function ReverseOperation(operation) { - const self = this; - const reverseOperation = operation; - self.undo = reverseOperation.redo; - self.redo = reverseOperation.undo; - } - - function GroupOperation(receiver, orphan) { - const self = this; - self.receiver = receiver; - self.orphan = orphan; - self.undo = function() { - self.orphan.parent = undefined; - }; - self.redo = function() { - self.orphan.parent = self.receiver; - }; +} + +function AddEdgeOperation(edge, owner) { + const self = this; + const vm = owner; + self.edge = edge; + self.undo = function() { + vm.arrRemove(vm.edges, self.edge); + delete vm.edgesMap[self.edge.id]; + }; + self.redo = function() { + vm.edges.push(self.edge); + vm.edgesMap[self.edge.id] = self.edge; + }; +} + +function ReverseOperation(operation) { + const self = this; + const reverseOperation = operation; + self.undo = reverseOperation.redo; + self.redo = reverseOperation.undo; +} + +function GroupOperation(receiver, orphan) { + const self = this; + self.receiver = receiver; + self.orphan = orphan; + self.undo = function() { + self.orphan.parent = undefined; + }; + self.redo = function() { + self.orphan.parent = self.receiver; + }; +} + +function UnGroupOperation(parent, child) { + const self = this; + self.parent = parent; + self.child = child; + self.undo = function() { + self.child.parent = self.parent; + }; + self.redo = function() { + self.child.parent = undefined; + }; +} + +// The main constructor for our GraphWorkspace +function GraphWorkspace(options) { + const self = this; + this.blacklistedNodes = []; + this.options = options; + this.undoLog = []; + this.redoLog = []; + this.selectedNodes = []; + + if (!options) { + this.options = {}; } - - function UnGroupOperation(parent, child) { - const self = this; - self.parent = parent; - self.child = child; - self.undo = function() { - self.child.parent = self.parent; - }; - self.redo = function() { - self.child.parent = undefined; - }; + this.nodesMap = {}; + this.edgesMap = {}; + this.searchTerm = ''; + + //A sequence number used to know when a node was added + this.seqNumber = 0; + + this.nodes = []; + this.edges = []; + this.lastRequest = null; + this.lastResponse = null; + this.changeHandler = options.changeHandler; + if (options.graphExploreProxy) { + graphExplorer = options.graphExploreProxy; } - - function createWorkspace(options) { - return new GraphWorkspace(options); + if (options.searchProxy) { + searcher = options.searchProxy; } - // The main constructor for our GraphWorkspace - function GraphWorkspace(options) { - const self = this; - this.blacklistedNodes = []; - this.options = options; - this.undoLog = []; - this.redoLog = []; - this.selectedNodes = []; - - if (!options) { - this.options = {}; + this.addUndoLogEntry = function(undoOperations) { + self.undoLog.push(undoOperations); + if (self.undoLog.length > 50) { + //Remove the oldest + self.undoLog.splice(0, 1); } - this.nodesMap = {}; - this.edgesMap = {}; - this.searchTerm = ''; - - //A sequence number used to know when a node was added - this.seqNumber = 0; + self.redoLog = []; + }; - this.nodes = []; - this.edges = []; - this.lastRequest = null; - this.lastResponse = null; - this.changeHandler = options.changeHandler; - if (options.graphExploreProxy) { - graphExplorer = options.graphExploreProxy; + this.undo = function() { + const lastOps = this.undoLog.pop(); + if (lastOps) { + this.stopLayout(); + this.redoLog.push(lastOps); + lastOps.forEach(ops => ops.undo()); + this.runLayout(); } - if (options.searchProxy) { - searcher = options.searchProxy; + }; + this.redo = function() { + const lastOps = this.redoLog.pop(); + if (lastOps) { + this.stopLayout(); + this.undoLog.push(lastOps); + lastOps.forEach(ops => ops.redo()); + this.runLayout(); } + }; - this.addUndoLogEntry = function(undoOperations) { - self.undoLog.push(undoOperations); - if (self.undoLog.length > 50) { - //Remove the oldest - self.undoLog.splice(0, 1); + //Determines if 2 nodes are connected via an edge + this.areLinked = function(a, b) { + if (a === b) return true; + this.edges.forEach(e => { + if (e.source === a && e.target === b) { + return true; } - self.redoLog = []; - }; - - this.undo = function() { - const lastOps = this.undoLog.pop(); - if (lastOps) { - this.stopLayout(); - this.redoLog.push(lastOps); - lastOps.forEach(ops => ops.undo()); - this.runLayout(); + if (e.source === b && e.target === a) { + return true; } - }; - this.redo = function() { - const lastOps = this.redoLog.pop(); - if (lastOps) { - this.stopLayout(); - this.undoLog.push(lastOps); - lastOps.forEach(ops => ops.redo()); - this.runLayout(); - } - }; - - //Determines if 2 nodes are connected via an edge - this.areLinked = function(a, b) { - if (a === b) return true; - this.edges.forEach(e => { - if (e.source === a && e.target === b) { - return true; - } - if (e.source === b && e.target === a) { - return true; - } - }); - return false; - }; + }); + return false; + }; - //======== Selection functions ======== + //======== Selection functions ======== - this.selectAll = function() { - self.selectedNodes = []; - self.nodes.forEach(node => { - if (node.parent === undefined) { - node.isSelected = true; - self.selectedNodes.push(node); - } else { - node.isSelected = false; - } - }); - }; - - this.selectNone = function() { - self.selectedNodes = []; - self.nodes.forEach(node => { + this.selectAll = function() { + self.selectedNodes = []; + self.nodes.forEach(node => { + if (node.parent === undefined) { + node.isSelected = true; + self.selectedNodes.push(node); + } else { node.isSelected = false; - }); - }; + } + }); + }; - this.selectInvert = function() { - self.selectedNodes = []; - self.nodes.forEach(node => { - if (node.parent !== undefined) { - return; - } - node.isSelected = !node.isSelected; - if (node.isSelected) { - self.selectedNodes.push(node); - } - }); - }; + this.selectNone = function() { + self.selectedNodes = []; + self.nodes.forEach(node => { + node.isSelected = false; + }); + }; - this.selectNodes = function(nodes) { - nodes.forEach(node => { - node.isSelected = true; - if (self.selectedNodes.indexOf(node) < 0) { - self.selectedNodes.push(node); - } - }); - }; + this.selectInvert = function() { + self.selectedNodes = []; + self.nodes.forEach(node => { + if (node.parent !== undefined) { + return; + } + node.isSelected = !node.isSelected; + if (node.isSelected) { + self.selectedNodes.push(node); + } + }); + }; - this.selectNode = function(node) { + this.selectNodes = function(nodes) { + nodes.forEach(node => { node.isSelected = true; if (self.selectedNodes.indexOf(node) < 0) { self.selectedNodes.push(node); } - }; + }); + }; - this.deleteSelection = function() { - let allAndGrouped = self.returnUnpackedGroupeds(self.selectedNodes); + this.selectNode = function(node) { + node.isSelected = true; + if (self.selectedNodes.indexOf(node) < 0) { + self.selectedNodes.push(node); + } + }; - // Nothing selected so process all nodes - if (allAndGrouped.length === 0) { - allAndGrouped = self.nodes.slice(0); - } + this.deleteSelection = function() { + let allAndGrouped = self.returnUnpackedGroupeds(self.selectedNodes); - const undoOperations = []; - allAndGrouped.forEach(node => { - //We set selected to false because despite being deleted, node objects sit in an undo log - node.isSelected = false; - delete self.nodesMap[node.id]; - undoOperations.push(new ReverseOperation(new AddNodeOperation(node, self))); - }); - self.arrRemoveAll(self.nodes, allAndGrouped); - self.arrRemoveAll(self.selectedNodes, allAndGrouped); + // Nothing selected so process all nodes + if (allAndGrouped.length === 0) { + allAndGrouped = self.nodes.slice(0); + } - const danglingEdges = self.edges.filter(function(edge) { - return self.nodes.indexOf(edge.source) < 0 || self.nodes.indexOf(edge.target) < 0; - }); - danglingEdges.forEach(edge => { - delete self.edgesMap[edge.id]; - undoOperations.push(new ReverseOperation(new AddEdgeOperation(edge, self))); - }); - self.addUndoLogEntry(undoOperations); - self.arrRemoveAll(self.edges, danglingEdges); - self.runLayout(); - }; + const undoOperations = []; + allAndGrouped.forEach(node => { + //We set selected to false because despite being deleted, node objects sit in an undo log + node.isSelected = false; + delete self.nodesMap[node.id]; + undoOperations.push(new ReverseOperation(new AddNodeOperation(node, self))); + }); + self.arrRemoveAll(self.nodes, allAndGrouped); + self.arrRemoveAll(self.selectedNodes, allAndGrouped); - this.selectNeighbours = function() { - const newSelections = []; - self.edges.forEach(edge => { - if (!edge.topSrc.isSelected) { - if (self.selectedNodes.indexOf(edge.topTarget) >= 0) { - if (newSelections.indexOf(edge.topSrc) < 0) { - newSelections.push(edge.topSrc); - } + const danglingEdges = self.edges.filter(function(edge) { + return self.nodes.indexOf(edge.source) < 0 || self.nodes.indexOf(edge.target) < 0; + }); + danglingEdges.forEach(edge => { + delete self.edgesMap[edge.id]; + undoOperations.push(new ReverseOperation(new AddEdgeOperation(edge, self))); + }); + self.addUndoLogEntry(undoOperations); + self.arrRemoveAll(self.edges, danglingEdges); + self.runLayout(); + }; + + this.selectNeighbours = function() { + const newSelections = []; + self.edges.forEach(edge => { + if (!edge.topSrc.isSelected) { + if (self.selectedNodes.indexOf(edge.topTarget) >= 0) { + if (newSelections.indexOf(edge.topSrc) < 0) { + newSelections.push(edge.topSrc); } } - if (!edge.topTarget.isSelected) { - if (self.selectedNodes.indexOf(edge.topSrc) >= 0) { - if (newSelections.indexOf(edge.topTarget) < 0) { - newSelections.push(edge.topTarget); - } + } + if (!edge.topTarget.isSelected) { + if (self.selectedNodes.indexOf(edge.topSrc) >= 0) { + if (newSelections.indexOf(edge.topTarget) < 0) { + newSelections.push(edge.topTarget); } } - }); - newSelections.forEach(newlySelectedNode => { - self.selectedNodes.push(newlySelectedNode); - newlySelectedNode.isSelected = true; - }); - }; - - this.selectNone = function() { - self.selectedNodes.forEach(node => { - node.isSelected = false; - }); - self.selectedNodes = []; - }; + } + }); + newSelections.forEach(newlySelectedNode => { + self.selectedNodes.push(newlySelectedNode); + newlySelectedNode.isSelected = true; + }); + }; - this.deselectNode = function(node) { + this.selectNone = function() { + self.selectedNodes.forEach(node => { node.isSelected = false; - self.arrRemove(self.selectedNodes, node); - }; - - this.getAllSelectedNodes = function() { - return this.returnUnpackedGroupeds(self.selectedNodes); - }; + }); + self.selectedNodes = []; + }; - this.colorSelected = function(colorNum) { - self.getAllSelectedNodes().forEach(node => { - node.color = colorNum; - }); - }; + this.deselectNode = function(node) { + node.isSelected = false; + self.arrRemove(self.selectedNodes, node); + }; - this.getSelectionsThatAreGrouped = function() { - const result = []; - self.selectedNodes.forEach(node => { - if (node.numChildren > 0) { - result.push(node); - } - }); - return result; - }; + this.getAllSelectedNodes = function() { + return this.returnUnpackedGroupeds(self.selectedNodes); + }; - this.ungroupSelection = function() { - self.getSelectionsThatAreGrouped().forEach(node => { - self.ungroup(node); - }); - }; + this.colorSelected = function(colorNum) { + self.getAllSelectedNodes().forEach(node => { + node.color = colorNum; + }); + }; - this.toggleNodeSelection = function(node) { - if (node.isSelected) { - self.deselectNode(node); - } else { - node.isSelected = true; - self.selectedNodes.push(node); + this.getSelectionsThatAreGrouped = function() { + const result = []; + self.selectedNodes.forEach(node => { + if (node.numChildren > 0) { + result.push(node); } - return node.isSelected; - }; + }); + return result; + }; - this.returnUnpackedGroupeds = function(topLevelNodeArray) { - //Gather any grouped nodes that are part of this top-level selection - const result = topLevelNodeArray.slice(); + this.ungroupSelection = function() { + self.getSelectionsThatAreGrouped().forEach(node => { + self.ungroup(node); + }); + }; - // We iterate over edges not nodes because edges conveniently hold the top-most - // node information. + this.toggleNodeSelection = function(node) { + if (node.isSelected) { + self.deselectNode(node); + } else { + node.isSelected = true; + self.selectedNodes.push(node); + } + return node.isSelected; + }; - const edges = this.edges; - for (let i = 0; i < edges.length; i++) { - const edge = edges[i]; + this.returnUnpackedGroupeds = function(topLevelNodeArray) { + //Gather any grouped nodes that are part of this top-level selection + const result = topLevelNodeArray.slice(); - const topLevelSource = edge.topSrc; - const topLevelTarget = edge.topTarget; + // We iterate over edges not nodes because edges conveniently hold the top-most + // node information. - if (result.indexOf(topLevelTarget) >= 0) { - //visible top-level node is selected - add all nesteds starting from bottom up - let target = edge.target; - while (target.parent !== undefined) { - if (result.indexOf(target) < 0) { - result.push(target); - } - target = target.parent; + const edges = this.edges; + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + + const topLevelSource = edge.topSrc; + const topLevelTarget = edge.topTarget; + + if (result.indexOf(topLevelTarget) >= 0) { + //visible top-level node is selected - add all nesteds starting from bottom up + let target = edge.target; + while (target.parent !== undefined) { + if (result.indexOf(target) < 0) { + result.push(target); } + target = target.parent; } + } - if (result.indexOf(topLevelSource) >= 0) { - //visible top-level node is selected - add all nesteds starting from bottom up - let source = edge.source; - while (source.parent !== undefined) { - if (result.indexOf(source) < 0) { - result.push(source); - } - source = source.parent; + if (result.indexOf(topLevelSource) >= 0) { + //visible top-level node is selected - add all nesteds starting from bottom up + let source = edge.source; + while (source.parent !== undefined) { + if (result.indexOf(source) < 0) { + result.push(source); } + source = source.parent; } - } //end of edges loop + } + } //end of edges loop - return result; - }; + return result; + }; - // ======= Miscellaneous functions + // ======= Miscellaneous functions - this.clearGraph = function() { - this.stopLayout(); - this.nodes = []; - this.edges = []; - this.undoLog = []; - this.redoLog = []; - this.nodesMap = {}; - this.edgesMap = {}; - this.blacklistedNodes = []; - this.selectedNodes = []; - this.lastResponse = null; - }; + this.clearGraph = function() { + this.stopLayout(); + this.nodes = []; + this.edges = []; + this.undoLog = []; + this.redoLog = []; + this.nodesMap = {}; + this.edgesMap = {}; + this.blacklistedNodes = []; + this.selectedNodes = []; + this.lastResponse = null; + }; - this.arrRemoveAll = function remove(arr, items) { - for (let i = items.length; i--; ) { - self.arrRemove(arr, items[i]); - } - }; + this.arrRemoveAll = function remove(arr, items) { + for (let i = items.length; i--; ) { + self.arrRemove(arr, items[i]); + } + }; - this.arrRemove = function remove(arr, item) { - for (let i = arr.length; i--; ) { - if (arr[i] === item) { - arr.splice(i, 1); - } + this.arrRemove = function remove(arr, item) { + for (let i = arr.length; i--; ) { + if (arr[i] === item) { + arr.splice(i, 1); } - }; + } + }; - this.getNeighbours = function(node) { - const neighbourNodes = []; - self.edges.forEach(edge => { - if (edge.topSrc === edge.topTarget) { - return; - } - if (edge.topSrc === node) { - if (neighbourNodes.indexOf(edge.topTarget) < 0) { - neighbourNodes.push(edge.topTarget); - } - } - if (edge.topTarget === node) { - if (neighbourNodes.indexOf(edge.topSrc) < 0) { - neighbourNodes.push(edge.topSrc); - } + this.getNeighbours = function(node) { + const neighbourNodes = []; + self.edges.forEach(edge => { + if (edge.topSrc === edge.topTarget) { + return; + } + if (edge.topSrc === node) { + if (neighbourNodes.indexOf(edge.topTarget) < 0) { + neighbourNodes.push(edge.topTarget); } - }); - return neighbourNodes; - }; - - //Creates a query that represents a node - either simple term query or boolean if grouped - this.buildNodeQuery = function(topLevelNode) { - let containedNodes = [topLevelNode]; - containedNodes = self.returnUnpackedGroupeds(containedNodes); - if (containedNodes.length === 1) { - //Simple case - return a single-term query - const tq = {}; - tq[topLevelNode.data.field] = topLevelNode.data.term; - return { - term: tq, - }; } - const termsByField = {}; - containedNodes.forEach(node => { - let termsList = termsByField[node.data.field]; - if (!termsList) { - termsList = []; - termsByField[node.data.field] = termsList; + if (edge.topTarget === node) { + if (neighbourNodes.indexOf(edge.topSrc) < 0) { + neighbourNodes.push(edge.topSrc); } - termsList.push(node.data.term); - }); - //Single field case - if (Object.keys(termsByField).length === 1) { - return { - terms: termsByField, - }; } - //Multi-field case - build a bool query with per-field terms clauses. - const q = { - bool: { - should: [], - }, + }); + return neighbourNodes; + }; + + //Creates a query that represents a node - either simple term query or boolean if grouped + this.buildNodeQuery = function(topLevelNode) { + let containedNodes = [topLevelNode]; + containedNodes = self.returnUnpackedGroupeds(containedNodes); + if (containedNodes.length === 1) { + //Simple case - return a single-term query + const tq = {}; + tq[topLevelNode.data.field] = topLevelNode.data.term; + return { + term: tq, }; - for (const field in termsByField) { - if (termsByField.hasOwnProperty(field)) { - const tq = {}; - tq[field] = termsByField[field]; - q.bool.should.push({ - terms: tq, - }); - } + } + const termsByField = {}; + containedNodes.forEach(node => { + let termsList = termsByField[node.data.field]; + if (!termsList) { + termsList = []; + termsByField[node.data.field] = termsList; } - return q; + termsList.push(node.data.term); + }); + //Single field case + if (Object.keys(termsByField).length === 1) { + return { + terms: termsByField, + }; + } + //Multi-field case - build a bool query with per-field terms clauses. + const q = { + bool: { + should: [], + }, }; + for (const field in termsByField) { + if (termsByField.hasOwnProperty(field)) { + const tq = {}; + tq[field] = termsByField[field]; + q.bool.should.push({ + terms: tq, + }); + } + } + return q; + }; - //====== Layout functions ======== + //====== Layout functions ======== - this.stopLayout = function() { - if (this.force) { - this.force.stop(); - } - this.force = null; - }; + this.stopLayout = function() { + if (this.force) { + this.force.stop(); + } + this.force = null; + }; - this.runLayout = function() { - this.stopLayout(); - // The set of nodes and edges we present to the d3 layout algorithms - // is potentially a reduced set of nodes if the client has used any - // grouping of nodes into parent nodes. - const effectiveEdges = []; - self.edges.forEach(edge => { - let topSrc = edge.source; - let topTarget = edge.target; - while (topSrc.parent !== undefined) { - topSrc = topSrc.parent; - } - while (topTarget.parent !== undefined) { - topTarget = topTarget.parent; - } - edge.topSrc = topSrc; - edge.topTarget = topTarget; + this.runLayout = function() { + this.stopLayout(); + // The set of nodes and edges we present to the d3 layout algorithms + // is potentially a reduced set of nodes if the client has used any + // grouping of nodes into parent nodes. + const effectiveEdges = []; + self.edges.forEach(edge => { + let topSrc = edge.source; + let topTarget = edge.target; + while (topSrc.parent !== undefined) { + topSrc = topSrc.parent; + } + while (topTarget.parent !== undefined) { + topTarget = topTarget.parent; + } + edge.topSrc = topSrc; + edge.topTarget = topTarget; - if (topSrc !== topTarget) { - effectiveEdges.push({ - source: topSrc, - target: topTarget, - }); - } - }); - const visibleNodes = self.nodes.filter(function(n) { - return n.parent === undefined; - }); - //reset then roll-up all the counts - const allNodes = self.nodes; - allNodes.forEach(node => { - node.numChildren = 0; - }); + if (topSrc !== topTarget) { + effectiveEdges.push({ + source: topSrc, + target: topTarget, + }); + } + }); + const visibleNodes = self.nodes.filter(function(n) { + return n.parent === undefined; + }); + //reset then roll-up all the counts + const allNodes = self.nodes; + allNodes.forEach(node => { + node.numChildren = 0; + }); - for (const n in allNodes) { - if (!allNodes.hasOwnProperty(n)) { - continue; - } - let node = allNodes[n]; - while (node.parent !== undefined) { - node = node.parent; - node.numChildren = node.numChildren + 1; - } + for (const n in allNodes) { + if (!allNodes.hasOwnProperty(n)) { + continue; + } + let node = allNodes[n]; + while (node.parent !== undefined) { + node = node.parent; + node.numChildren = node.numChildren + 1; } - this.force = d3.layout - .force() - .nodes(visibleNodes) - .links(effectiveEdges) - .friction(0.8) - .linkDistance(100) - .charge(-1500) - .gravity(0.15) - .theta(0.99) - .alpha(0.5) - .size([800, 600]) - .on('tick', function() { - const nodeArray = self.nodes; - let hasRollups = false; - //Update the position of all "top level nodes" + } + this.force = d3.layout + .force() + .nodes(visibleNodes) + .links(effectiveEdges) + .friction(0.8) + .linkDistance(100) + .charge(-1500) + .gravity(0.15) + .theta(0.99) + .alpha(0.5) + .size([800, 600]) + .on('tick', function() { + const nodeArray = self.nodes; + let hasRollups = false; + //Update the position of all "top level nodes" + nodeArray.forEach(n => { + //Code to support roll-ups + if (n.parent === undefined) { + n.kx = n.x; + n.ky = n.y; + } else { + hasRollups = true; + } + }); + if (hasRollups) { nodeArray.forEach(n => { //Code to support roll-ups - if (n.parent === undefined) { - n.kx = n.x; - n.ky = n.y; - } else { - hasRollups = true; - } - }); - if (hasRollups) { - nodeArray.forEach(n => { - //Code to support roll-ups - if (n.parent !== undefined) { - // Is a grouped node - inherit parent's position so edges point into parent - // d3 thinks it has moved it to x and y but we have final say using kx and ky. - let topLevelNode = n.parent; - while (topLevelNode.parent !== undefined) { - topLevelNode = topLevelNode.parent; - } - - n.kx = topLevelNode.x; - n.ky = topLevelNode.y; + if (n.parent !== undefined) { + // Is a grouped node - inherit parent's position so edges point into parent + // d3 thinks it has moved it to x and y but we have final say using kx and ky. + let topLevelNode = n.parent; + while (topLevelNode.parent !== undefined) { + topLevelNode = topLevelNode.parent; } - }); - } - if (self.changeHandler) { - // Hook to allow any client to respond to position changes - // e.g. angular adjusts and repaints node positions on screen. - self.changeHandler(); - } - }); - this.force.start(); - }; - //========Grouping functions========== - - //Merges all selected nodes into node - this.groupSelections = function(node) { - const ops = []; - self.nodes.forEach(function(otherNode) { - if (otherNode !== node && otherNode.isSelected && otherNode.parent === undefined) { - otherNode.parent = node; - otherNode.isSelected = false; - self.arrRemove(self.selectedNodes, otherNode); - ops.push(new GroupOperation(node, otherNode)); + n.kx = topLevelNode.x; + n.ky = topLevelNode.y; + } + }); } - }); - self.selectNone(); - self.selectNode(node); - self.addUndoLogEntry(ops); - self.runLayout(); - }; - - this.mergeNeighbours = function(node) { - const neighbours = self.getNeighbours(node); - const ops = []; - neighbours.forEach(function(otherNode) { - if (otherNode !== node && otherNode.parent === undefined) { - otherNode.parent = node; - otherNode.isSelected = false; - self.arrRemove(self.selectedNodes, otherNode); - ops.push(new GroupOperation(node, otherNode)); + if (self.changeHandler) { + // Hook to allow any client to respond to position changes + // e.g. angular adjusts and repaints node positions on screen. + self.changeHandler(); } }); - self.addUndoLogEntry(ops); - self.runLayout(); - }; + this.force.start(); + }; - this.mergeSelections = function(targetNode) { - if (!targetNode) { - console.log('Error - merge called on undefined target'); - return; + //========Grouping functions========== + + //Merges all selected nodes into node + this.groupSelections = function(node) { + const ops = []; + self.nodes.forEach(function(otherNode) { + if (otherNode !== node && otherNode.isSelected && otherNode.parent === undefined) { + otherNode.parent = node; + otherNode.isSelected = false; + self.arrRemove(self.selectedNodes, otherNode); + ops.push(new GroupOperation(node, otherNode)); } - const selClone = self.selectedNodes.slice(); - const ops = []; - selClone.forEach(function(otherNode) { - if (otherNode !== targetNode && otherNode.parent === undefined) { - otherNode.parent = targetNode; - otherNode.isSelected = false; - self.arrRemove(self.selectedNodes, otherNode); - ops.push(new GroupOperation(targetNode, otherNode)); - } - }); - self.addUndoLogEntry(ops); - self.runLayout(); - }; - - this.ungroup = function(node) { - const ops = []; - self.nodes.forEach(function(other) { - if (other.parent === node) { - other.parent = undefined; - ops.push(new UnGroupOperation(node, other)); - } - }); - self.addUndoLogEntry(ops); - self.runLayout(); - }; - - this.unblacklist = function(node) { - self.arrRemove(self.blacklistedNodes, node); - }; - - this.blacklistSelection = function() { - const selection = self.getAllSelectedNodes(); - const danglingEdges = []; - self.edges.forEach(function(edge) { - if (selection.indexOf(edge.source) >= 0 || selection.indexOf(edge.target) >= 0) { - delete self.edgesMap[edge.id]; - danglingEdges.push(edge); - } - }); - selection.forEach(node => { - delete self.nodesMap[node.id]; - self.blacklistedNodes.push(node); - node.isSelected = false; - }); - self.arrRemoveAll(self.nodes, selection); - self.arrRemoveAll(self.edges, danglingEdges); - self.selectedNodes = []; - self.runLayout(); - }; - - // A "simple search" operation that requires no parameters from the client. - // Performs numHops hops pulling in field-specific number of terms each time - this.simpleSearch = function(searchTerm, fieldsChoice, numHops) { - const qs = { - query_string: { - query: searchTerm, - }, - }; - return this.search(qs, fieldsChoice, numHops); - }; + }); + self.selectNone(); + self.selectNode(node); + self.addUndoLogEntry(ops); + self.runLayout(); + }; - this.search = function(query, fieldsChoice, numHops) { - if (!fieldsChoice) { - fieldsChoice = self.options.vertex_fields; + this.mergeNeighbours = function(node) { + const neighbours = self.getNeighbours(node); + const ops = []; + neighbours.forEach(function(otherNode) { + if (otherNode !== node && otherNode.parent === undefined) { + otherNode.parent = node; + otherNode.isSelected = false; + self.arrRemove(self.selectedNodes, otherNode); + ops.push(new GroupOperation(node, otherNode)); } - let step = {}; - - //Add any blacklisted nodes to exclusion list - const excludeNodesByField = {}; - const nots = []; - const avoidNodes = this.blacklistedNodes; - for (let i = 0; i < avoidNodes.length; i++) { - const n = avoidNodes[i]; - let arr = excludeNodesByField[n.data.field]; - if (!arr) { - arr = []; - excludeNodesByField[n.data.field] = arr; - } - arr.push(n.data.term); - //Add to list of must_nots in guiding query - const tq = {}; - tq[n.data.field] = n.data.term; - nots.push({ - term: tq, - }); - } - - const rootStep = step; - for (let hopNum = 0; hopNum < numHops; hopNum++) { - const arr = []; + }); + self.addUndoLogEntry(ops); + self.runLayout(); + }; - fieldsChoice.forEach(({ name: field, hopSize }) => { - const excludes = excludeNodesByField[field]; - const stepField = { - field: field, - size: hopSize, - min_doc_count: parseInt(self.options.exploreControls.minDocCount), - }; - if (excludes) { - stepField.exclude = excludes; - } - arr.push(stepField); - }); - step.vertices = arr; - if (hopNum < numHops - 1) { - // if (s < (stepSizes.length - 1)) { - const nextStep = {}; - step.connections = nextStep; - step = nextStep; - } + this.mergeSelections = function(targetNode) { + if (!targetNode) { + console.log('Error - merge called on undefined target'); + return; + } + const selClone = self.selectedNodes.slice(); + const ops = []; + selClone.forEach(function(otherNode) { + if (otherNode !== targetNode && otherNode.parent === undefined) { + otherNode.parent = targetNode; + otherNode.isSelected = false; + self.arrRemove(self.selectedNodes, otherNode); + ops.push(new GroupOperation(targetNode, otherNode)); } + }); + self.addUndoLogEntry(ops); + self.runLayout(); + }; - if (nots.length > 0) { - query = { - bool: { - must: [query], - must_not: nots, - }, - }; + this.ungroup = function(node) { + const ops = []; + self.nodes.forEach(function(other) { + if (other.parent === node) { + other.parent = undefined; + ops.push(new UnGroupOperation(node, other)); } + }); + self.addUndoLogEntry(ops); + self.runLayout(); + }; - const request = { - query: query, - controls: self.buildControls(), - connections: rootStep.connections, - vertices: rootStep.vertices, - }; - self.callElasticsearch(request); - }; - - this.buildControls = function() { - //This is an object managed by the client that may be subject to change - const guiSettingsObj = self.options.exploreControls; + this.unblacklist = function(node) { + self.arrRemove(self.blacklistedNodes, node); + }; - const controls = { - use_significance: guiSettingsObj.useSignificance, - sample_size: guiSettingsObj.sampleSize, - timeout: parseInt(guiSettingsObj.timeoutMillis), - }; - // console.log("guiSettingsObj",guiSettingsObj); - if (guiSettingsObj.sampleDiversityField != null) { - controls.sample_diversity = { - field: guiSettingsObj.sampleDiversityField.name, - max_docs_per_value: guiSettingsObj.maxValuesPerDoc, - }; + this.blacklistSelection = function() { + const selection = self.getAllSelectedNodes(); + const danglingEdges = []; + self.edges.forEach(function(edge) { + if (selection.indexOf(edge.source) >= 0 || selection.indexOf(edge.target) >= 0) { + delete self.edgesMap[edge.id]; + danglingEdges.push(edge); } - return controls; - }; + }); + selection.forEach(node => { + delete self.nodesMap[node.id]; + self.blacklistedNodes.push(node); + node.isSelected = false; + }); + self.arrRemoveAll(self.nodes, selection); + self.arrRemoveAll(self.edges, danglingEdges); + self.selectedNodes = []; + self.runLayout(); + }; - this.makeNodeId = function(field, term) { - return field + '..' + term; + // A "simple search" operation that requires no parameters from the client. + // Performs numHops hops pulling in field-specific number of terms each time + this.simpleSearch = function(searchTerm, fieldsChoice, numHops) { + const qs = { + query_string: { + query: searchTerm, + }, }; + return this.search(qs, fieldsChoice, numHops); + }; - this.makeEdgeId = function(srcId, targetId) { - let id = srcId + '->' + targetId; - if (srcId > targetId) { - id = targetId + '->' + srcId; + this.search = function(query, fieldsChoice, numHops) { + if (!fieldsChoice) { + fieldsChoice = self.options.vertex_fields; + } + let step = {}; + + //Add any blacklisted nodes to exclusion list + const excludeNodesByField = {}; + const nots = []; + const avoidNodes = this.blacklistedNodes; + for (let i = 0; i < avoidNodes.length; i++) { + const n = avoidNodes[i]; + let arr = excludeNodesByField[n.data.field]; + if (!arr) { + arr = []; + excludeNodesByField[n.data.field] = arr; } - return id; - }; + arr.push(n.data.term); + //Add to list of must_nots in guiding query + const tq = {}; + tq[n.data.field] = n.data.term; + nots.push({ + term: tq, + }); + } - //======= Adds new nodes retrieved from an elasticsearch search ======== - this.mergeGraph = function(newData) { - this.stopLayout(); + const rootStep = step; + for (let hopNum = 0; hopNum < numHops; hopNum++) { + const arr = []; - if (!newData.nodes) { - newData.nodes = []; - } - const lastOps = []; - - // === Commented out - not sure it was obvious to users what various circle sizes meant - // var minCircleSize = 5; - // var maxCircleSize = 25; - // var sizeScale = d3.scale.pow().exponent(0.15) - // .domain([0, d3.max(newData.nodes, function(d) { - // return d.weight; - // })]) - // .range([minCircleSize, maxCircleSize]); - - //Remove nodes we already have - const dedupedNodes = []; - newData.nodes.forEach(node => { - //Assign an ID - node.id = self.makeNodeId(node.field, node.term); - if (!this.nodesMap[node.id]) { - //Default the label - if (!node.label) { - node.label = node.term; - } - dedupedNodes.push(node); + fieldsChoice.forEach(({ name: field, hopSize }) => { + const excludes = excludeNodesByField[field]; + const stepField = { + field: field, + size: hopSize, + min_doc_count: parseInt(self.options.exploreControls.minDocCount), + }; + if (excludes) { + stepField.exclude = excludes; } + arr.push(stepField); }); - if (dedupedNodes.length > 0 && this.options.nodeLabeller) { - // A hook for client code to attach labels etc to newly introduced nodes. - this.options.nodeLabeller(dedupedNodes); + step.vertices = arr; + if (hopNum < numHops - 1) { + // if (s < (stepSizes.length - 1)) { + const nextStep = {}; + step.connections = nextStep; + step = nextStep; } + } - dedupedNodes.forEach(dedupedNode => { - let label = dedupedNode.term; - if (dedupedNode.label) { - label = dedupedNode.label; - } + if (nots.length > 0) { + query = { + bool: { + must: [query], + must_not: nots, + }, + }; + } - const node = { - x: 1, - y: 1, - numChildren: 0, - parent: undefined, - isSelected: false, - id: dedupedNode.id, - label: label, - color: dedupedNode.color, - icon: dedupedNode.icon, - data: dedupedNode, - }; - // node.scaledSize = sizeScale(node.data.weight); - node.scaledSize = 15; - node.seqNumber = this.seqNumber++; - this.nodes.push(node); - lastOps.push(new AddNodeOperation(node, self)); - this.nodesMap[node.id] = node; - }); + const request = { + query: query, + controls: self.buildControls(), + connections: rootStep.connections, + vertices: rootStep.vertices, + }; + self.callElasticsearch(request); + }; - newData.edges.forEach(edge => { - const src = newData.nodes[edge.source]; - const target = newData.nodes[edge.target]; - edge.id = this.makeEdgeId(src.id, target.id); + this.buildControls = function() { + //This is an object managed by the client that may be subject to change + const guiSettingsObj = self.options.exploreControls; - //Lookup the wrappers object that will hold display Info like x/y coordinates - const srcWrapperObj = this.nodesMap[src.id]; - const targetWrapperObj = this.nodesMap[target.id]; + const controls = { + use_significance: guiSettingsObj.useSignificance, + sample_size: guiSettingsObj.sampleSize, + timeout: parseInt(guiSettingsObj.timeoutMillis), + }; + // console.log("guiSettingsObj",guiSettingsObj); + if (guiSettingsObj.sampleDiversityField != null) { + controls.sample_diversity = { + field: guiSettingsObj.sampleDiversityField.name, + max_docs_per_value: guiSettingsObj.maxValuesPerDoc, + }; + } + return controls; + }; - const existingEdge = this.edgesMap[edge.id]; - if (existingEdge) { - existingEdge.weight = Math.max(existingEdge.weight, edge.weight); - //TODO update width too? - existingEdge.doc_count = Math.max(existingEdge.doc_count, edge.doc_count); - return; - } - const newEdge = { - source: srcWrapperObj, - target: targetWrapperObj, - weight: edge.weight, - width: edge.width, - id: edge.id, - doc_count: edge.doc_count, - }; - if (edge.label) { - newEdge.label = edge.label; - } + this.makeNodeId = function(field, term) { + return field + '..' + term; + }; - this.edgesMap[newEdge.id] = newEdge; - this.edges.push(newEdge); - lastOps.push(new AddEdgeOperation(newEdge, self)); - }); + this.makeEdgeId = function(srcId, targetId) { + let id = srcId + '->' + targetId; + if (srcId > targetId) { + id = targetId + '->' + srcId; + } + return id; + }; + + //======= Adds new nodes retrieved from an elasticsearch search ======== + this.mergeGraph = function(newData) { + this.stopLayout(); + + if (!newData.nodes) { + newData.nodes = []; + } + const lastOps = []; + + // === Commented out - not sure it was obvious to users what various circle sizes meant + // var minCircleSize = 5; + // var maxCircleSize = 25; + // var sizeScale = d3.scale.pow().exponent(0.15) + // .domain([0, d3.max(newData.nodes, function(d) { + // return d.weight; + // })]) + // .range([minCircleSize, maxCircleSize]); + + //Remove nodes we already have + const dedupedNodes = []; + newData.nodes.forEach(node => { + //Assign an ID + node.id = self.makeNodeId(node.field, node.term); + if (!this.nodesMap[node.id]) { + //Default the label + if (!node.label) { + node.label = node.term; + } + dedupedNodes.push(node); + } + }); + if (dedupedNodes.length > 0 && this.options.nodeLabeller) { + // A hook for client code to attach labels etc to newly introduced nodes. + this.options.nodeLabeller(dedupedNodes); + } - if (lastOps.length > 0) { - self.addUndoLogEntry(lastOps); + dedupedNodes.forEach(dedupedNode => { + let label = dedupedNode.term; + if (dedupedNode.label) { + label = dedupedNode.label; } - this.runLayout(); - }; + const node = { + x: 1, + y: 1, + numChildren: 0, + parent: undefined, + isSelected: false, + id: dedupedNode.id, + label: label, + color: dedupedNode.color, + icon: dedupedNode.icon, + data: dedupedNode, + }; + // node.scaledSize = sizeScale(node.data.weight); + node.scaledSize = 15; + node.seqNumber = this.seqNumber++; + this.nodes.push(node); + lastOps.push(new AddNodeOperation(node, self)); + this.nodesMap[node.id] = node; + }); - this.mergeIds = function(parentId, childId) { - const parent = self.getNode(parentId); - const child = self.getNode(childId); - if (child.isSelected) { - child.isSelected = false; - self.arrRemove(self.selectedNodes, child); + newData.edges.forEach(edge => { + const src = newData.nodes[edge.source]; + const target = newData.nodes[edge.target]; + edge.id = this.makeEdgeId(src.id, target.id); + + //Lookup the wrappers object that will hold display Info like x/y coordinates + const srcWrapperObj = this.nodesMap[src.id]; + const targetWrapperObj = this.nodesMap[target.id]; + + const existingEdge = this.edgesMap[edge.id]; + if (existingEdge) { + existingEdge.weight = Math.max(existingEdge.weight, edge.weight); + //TODO update width too? + existingEdge.doc_count = Math.max(existingEdge.doc_count, edge.doc_count); + return; + } + const newEdge = { + source: srcWrapperObj, + target: targetWrapperObj, + weight: edge.weight, + width: edge.width, + id: edge.id, + doc_count: edge.doc_count, + }; + if (edge.label) { + newEdge.label = edge.label; } - child.parent = parent; - self.addUndoLogEntry([new GroupOperation(parent, child)]); - self.runLayout(); - }; - this.getNode = function(nodeId) { - return this.nodesMap[nodeId]; - }; - this.getEdge = function(edgeId) { - return this.edgesMap[edgeId]; - }; + this.edgesMap[newEdge.id] = newEdge; + this.edges.push(newEdge); + lastOps.push(new AddEdgeOperation(newEdge, self)); + }); - //======= Expand functions to request new additions to the graph + if (lastOps.length > 0) { + self.addUndoLogEntry(lastOps); + } - this.expandSelecteds = function(targetOptions = {}) { - let startNodes = self.getAllSelectedNodes(); - if (startNodes.length === 0) { - startNodes = self.nodes; - } - const clone = startNodes.slice(); - self.expand(clone, targetOptions); - }; + this.runLayout(); + }; - this.expandGraph = function() { - self.expandSelecteds(); - }; + this.mergeIds = function(parentId, childId) { + const parent = self.getNode(parentId); + const child = self.getNode(childId); + if (child.isSelected) { + child.isSelected = false; + self.arrRemove(self.selectedNodes, child); + } + child.parent = parent; + self.addUndoLogEntry([new GroupOperation(parent, child)]); + self.runLayout(); + }; - //Find new nodes to link to existing selected nodes - this.expandNode = function(node) { - self.expand(self.returnUnpackedGroupeds([node]), {}); - }; + this.getNode = function(nodeId) { + return this.nodesMap[nodeId]; + }; + this.getEdge = function(edgeId) { + return this.edgesMap[edgeId]; + }; - // A manual expand function where the client provides the list - // of existing nodes that are the start points and some options - // about what targets are of interest. - this.expand = function(startNodes, targetOptions) { - //============================= - const nodesByField = {}; - const excludeNodesByField = {}; - - //Add any blacklisted nodes to exclusion list - const avoidNodes = this.blacklistedNodes; - for (let i = 0; i < avoidNodes.length; i++) { - const n = avoidNodes[i]; - let arr = excludeNodesByField[n.data.field]; - if (!arr) { - arr = []; - excludeNodesByField[n.data.field] = arr; - } - if (arr.indexOf(n.data.term) < 0) { - arr.push(n.data.term); - } - } + //======= Expand functions to request new additions to the graph - const allExistingNodes = this.nodes; - for (let i = 0; i < allExistingNodes.length; i++) { - const n = allExistingNodes[i]; - let arr = excludeNodesByField[n.data.field]; - if (!arr) { - arr = []; - excludeNodesByField[n.data.field] = arr; - } - arr.push(n.data.term); - } + this.expandSelecteds = function(targetOptions = {}) { + let startNodes = self.getAllSelectedNodes(); + if (startNodes.length === 0) { + startNodes = self.nodes; + } + const clone = startNodes.slice(); + self.expand(clone, targetOptions); + }; - //Organize nodes by field - for (let i = 0; i < startNodes.length; i++) { - const n = startNodes[i]; - let arr = nodesByField[n.data.field]; - if (!arr) { - arr = []; - nodesByField[n.data.field] = arr; - } - // pushing boosts server-side to influence sampling/direction - arr.push({ - term: n.data.term, - boost: n.data.weight, - }); + this.expandGraph = function() { + self.expandSelecteds(); + }; - arr = excludeNodesByField[n.data.field]; - if (!arr) { - arr = []; - excludeNodesByField[n.data.field] = arr; - } - //NOTE for the entity-building use case need to remove excludes that otherwise - // prevent bridge-building. - if (arr.indexOf(n.data.term) < 0) { - arr.push(n.data.term); - } + //Find new nodes to link to existing selected nodes + this.expandNode = function(node) { + self.expand(self.returnUnpackedGroupeds([node]), {}); + }; + + // A manual expand function where the client provides the list + // of existing nodes that are the start points and some options + // about what targets are of interest. + this.expand = function(startNodes, targetOptions) { + //============================= + const nodesByField = {}; + const excludeNodesByField = {}; + + //Add any blacklisted nodes to exclusion list + const avoidNodes = this.blacklistedNodes; + for (let i = 0; i < avoidNodes.length; i++) { + const n = avoidNodes[i]; + let arr = excludeNodesByField[n.data.field]; + if (!arr) { + arr = []; + excludeNodesByField[n.data.field] = arr; } + if (arr.indexOf(n.data.term) < 0) { + arr.push(n.data.term); + } + } - const primaryVertices = []; - const secondaryVertices = []; - for (const fieldName in nodesByField) { - if (nodesByField.hasOwnProperty(fieldName)) { - primaryVertices.push({ - field: fieldName, - include: nodesByField[fieldName], - min_doc_count: parseInt(self.options.exploreControls.minDocCount), - }); - } + const allExistingNodes = this.nodes; + for (let i = 0; i < allExistingNodes.length; i++) { + const n = allExistingNodes[i]; + let arr = excludeNodesByField[n.data.field]; + if (!arr) { + arr = []; + excludeNodesByField[n.data.field] = arr; } + arr.push(n.data.term); + } - let targetFields = this.options.vertex_fields; - if (targetOptions.toFields) { - targetFields = targetOptions.toFields; + //Organize nodes by field + for (let i = 0; i < startNodes.length; i++) { + const n = startNodes[i]; + let arr = nodesByField[n.data.field]; + if (!arr) { + arr = []; + nodesByField[n.data.field] = arr; } + // pushing boosts server-side to influence sampling/direction + arr.push({ + term: n.data.term, + boost: n.data.weight, + }); - //Identify target fields - targetFields.forEach(targetField => { - const fieldName = targetField.name; - // Sometimes the target field is disabled from loading new hops so we need to use the last valid figure - const hopSize = - targetField.hopSize > 0 ? targetField.hopSize : targetField.lastValidHopSize; + arr = excludeNodesByField[n.data.field]; + if (!arr) { + arr = []; + excludeNodesByField[n.data.field] = arr; + } + //NOTE for the entity-building use case need to remove excludes that otherwise + // prevent bridge-building. + if (arr.indexOf(n.data.term) < 0) { + arr.push(n.data.term); + } + } - const fieldHop = { + const primaryVertices = []; + const secondaryVertices = []; + for (const fieldName in nodesByField) { + if (nodesByField.hasOwnProperty(fieldName)) { + primaryVertices.push({ field: fieldName, - size: hopSize, + include: nodesByField[fieldName], min_doc_count: parseInt(self.options.exploreControls.minDocCount), - }; - fieldHop.exclude = excludeNodesByField[fieldName]; - secondaryVertices.push(fieldHop); - }); + }); + } + } - const request = { - controls: self.buildControls(), - vertices: primaryVertices, - connections: { - vertices: secondaryVertices, - }, + let targetFields = this.options.vertex_fields; + if (targetOptions.toFields) { + targetFields = targetOptions.toFields; + } + + //Identify target fields + targetFields.forEach(targetField => { + const fieldName = targetField.name; + // Sometimes the target field is disabled from loading new hops so we need to use the last valid figure + const hopSize = targetField.hopSize > 0 ? targetField.hopSize : targetField.lastValidHopSize; + + const fieldHop = { + field: fieldName, + size: hopSize, + min_doc_count: parseInt(self.options.exploreControls.minDocCount), }; - self.lastRequest = JSON.stringify(request, null, '\t'); - graphExplorer(self.options.indexName, request, function(data) { - self.lastResponse = JSON.stringify(data, null, '\t'); - const edges = []; - - //Label fields with a field number for CSS styling - data.vertices.forEach(node => { - targetFields.some(fieldDef => { - if (node.field === fieldDef.name) { - node.color = fieldDef.color; - node.icon = fieldDef.icon; - node.fieldDef = fieldDef; - return true; - } - return false; - }); - }); + fieldHop.exclude = excludeNodesByField[fieldName]; + secondaryVertices.push(fieldHop); + }); - // Size the edges based on the maximum weight - const minLineSize = 2; - const maxLineSize = 10; - let maxEdgeWeight = 0.00000001; - data.connections.forEach(edge => { - maxEdgeWeight = Math.max(maxEdgeWeight, edge.weight); - edges.push({ - source: edge.source, - target: edge.target, - doc_count: edge.doc_count, - weight: edge.weight, - width: Math.max(minLineSize, (edge.weight / maxEdgeWeight) * maxLineSize), - }); + const request = { + controls: self.buildControls(), + vertices: primaryVertices, + connections: { + vertices: secondaryVertices, + }, + }; + self.lastRequest = JSON.stringify(request, null, '\t'); + graphExplorer(self.options.indexName, request, function(data) { + self.lastResponse = JSON.stringify(data, null, '\t'); + const edges = []; + + //Label fields with a field number for CSS styling + data.vertices.forEach(node => { + targetFields.some(fieldDef => { + if (node.field === fieldDef.name) { + node.color = fieldDef.color; + node.icon = fieldDef.icon; + node.fieldDef = fieldDef; + return true; + } + return false; }); + }); - // Add the new nodes and edges into the existing workspace's graph - self.mergeGraph({ - nodes: data.vertices, - edges: edges, + // Size the edges based on the maximum weight + const minLineSize = 2; + const maxLineSize = 10; + let maxEdgeWeight = 0.00000001; + data.connections.forEach(edge => { + maxEdgeWeight = Math.max(maxEdgeWeight, edge.weight); + edges.push({ + source: edge.source, + target: edge.target, + doc_count: edge.doc_count, + weight: edge.weight, + width: Math.max(minLineSize, (edge.weight / maxEdgeWeight) * maxLineSize), }); }); - //===== End expand graph ======================== - }; - this.trimExcessNewEdges = function(newNodes, newEdges) { - let trimmedEdges = []; - const maxNumEdgesToReturn = 5; - //Trim here to just the new edges that are most interesting. - newEdges.forEach(edge => { - const src = newNodes[edge.source]; - const target = newNodes[edge.target]; - const srcId = src.field + '..' + src.term; - const targetId = target.field + '..' + target.term; - const id = this.makeEdgeId(srcId, targetId); - const existingSrcNode = self.nodesMap[srcId]; - const existingTargetNode = self.nodesMap[targetId]; - if (existingSrcNode != null && existingTargetNode != null) { - if (existingSrcNode.parent !== undefined && existingTargetNode.parent !== undefined) { - // both nodes are rolled-up and grouped so this edge would not be a visible - // change to the graph - lose it in favour of any other visible ones. - return; - } - } else { - console.log('Error? Missing nodes ' + srcId + ' or ' + targetId, self.nodesMap); - return; - } + // Add the new nodes and edges into the existing workspace's graph + self.mergeGraph({ + nodes: data.vertices, + edges: edges, + }); + }); + //===== End expand graph ======================== + }; - const existingEdge = self.edgesMap[id]; - if (existingEdge) { - existingEdge.weight = Math.max(existingEdge.weight, edge.weight); - existingEdge.doc_count = Math.max(existingEdge.doc_count, edge.doc_count); + this.trimExcessNewEdges = function(newNodes, newEdges) { + let trimmedEdges = []; + const maxNumEdgesToReturn = 5; + //Trim here to just the new edges that are most interesting. + newEdges.forEach(edge => { + const src = newNodes[edge.source]; + const target = newNodes[edge.target]; + const srcId = src.field + '..' + src.term; + const targetId = target.field + '..' + target.term; + const id = this.makeEdgeId(srcId, targetId); + const existingSrcNode = self.nodesMap[srcId]; + const existingTargetNode = self.nodesMap[targetId]; + if (existingSrcNode != null && existingTargetNode != null) { + if (existingSrcNode.parent !== undefined && existingTargetNode.parent !== undefined) { + // both nodes are rolled-up and grouped so this edge would not be a visible + // change to the graph - lose it in favour of any other visible ones. return; - } else { - trimmedEdges.push(edge); } - }); - if (trimmedEdges.length > maxNumEdgesToReturn) { - //trim to only the most interesting ones - trimmedEdges.sort(function(a, b) { - return b.weight - a.weight; - }); - trimmedEdges = trimmedEdges.splice(0, maxNumEdgesToReturn); + } else { + console.log('Error? Missing nodes ' + srcId + ' or ' + targetId, self.nodesMap); + return; } - return trimmedEdges; - }; - this.getQuery = function(startNodes, loose) { - const shoulds = []; - let nodes = startNodes; - if (!startNodes) { - nodes = self.nodes; + const existingEdge = self.edgesMap[id]; + if (existingEdge) { + existingEdge.weight = Math.max(existingEdge.weight, edge.weight); + existingEdge.doc_count = Math.max(existingEdge.doc_count, edge.doc_count); + return; + } else { + trimmedEdges.push(edge); } - nodes.forEach(node => { - if (node.parent === undefined) { - shoulds.push(self.buildNodeQuery(node)); - } + }); + if (trimmedEdges.length > maxNumEdgesToReturn) { + //trim to only the most interesting ones + trimmedEdges.sort(function(a, b) { + return b.weight - a.weight; }); - return { - bool: { - should: shoulds, - minimum_should_match: Math.min(shoulds.length, loose ? 1 : 2), - }, - }; - }; + trimmedEdges = trimmedEdges.splice(0, maxNumEdgesToReturn); + } + return trimmedEdges; + }; - this.getSelectedOrAllNodes = function() { - let startNodes = self.getAllSelectedNodes(); - if (startNodes.length === 0) { - startNodes = self.nodes; + this.getQuery = function(startNodes, loose) { + const shoulds = []; + let nodes = startNodes; + if (!startNodes) { + nodes = self.nodes; + } + nodes.forEach(node => { + if (node.parent === undefined) { + shoulds.push(self.buildNodeQuery(node)); } - return startNodes; + }); + return { + bool: { + should: shoulds, + minimum_should_match: Math.min(shoulds.length, loose ? 1 : 2), + }, }; + }; - this.getSelectedOrAllTopNodes = function() { - return self.getSelectedOrAllNodes().filter(function(node) { - return node.parent === undefined; - }); - }; + this.getSelectedOrAllNodes = function() { + let startNodes = self.getAllSelectedNodes(); + if (startNodes.length === 0) { + startNodes = self.nodes; + } + return startNodes; + }; - function addTermToFieldList(map, field, term) { - let arr = map[field]; - if (!arr) { - arr = []; - map[field] = arr; - } - arr.push(term); + this.getSelectedOrAllTopNodes = function() { + return self.getSelectedOrAllNodes().filter(function(node) { + return node.parent === undefined; + }); + }; + + function addTermToFieldList(map, field, term) { + let arr = map[field]; + if (!arr) { + arr = []; + map[field] = arr; } + arr.push(term); + } - /** - * Add missing links between existing nodes - * @param maxNewEdges Max number of new edges added. Avoid adding too many new edges - * at once into the graph otherwise disorientating - */ - this.fillInGraph = function(maxNewEdges = 10) { - let nodesForLinking = self.getSelectedOrAllTopNodes(); - - const maxNumVerticesSearchable = 100; - - // Server limitation - we can only search for connections between max 100 vertices at a time. - if (nodesForLinking.length > maxNumVerticesSearchable) { - //Make a selection of random nodes from the array. Shift the random choices - // to the front of the array. - for (let i = 0; i < maxNumVerticesSearchable; i++) { - const oldNode = nodesForLinking[i]; - const randomIndex = Math.floor(Math.random() * (nodesForLinking.length - i)) + i; - //Swap the node positions of the randomly selected node and i - nodesForLinking[i] = nodesForLinking[randomIndex]; - nodesForLinking[randomIndex] = oldNode; - } - // Trim to our random selection - nodesForLinking = nodesForLinking.slice(0, maxNumVerticesSearchable - 1); + /** + * Add missing links between existing nodes + * @param maxNewEdges Max number of new edges added. Avoid adding too many new edges + * at once into the graph otherwise disorientating + */ + this.fillInGraph = function(maxNewEdges = 10) { + let nodesForLinking = self.getSelectedOrAllTopNodes(); + + const maxNumVerticesSearchable = 100; + + // Server limitation - we can only search for connections between max 100 vertices at a time. + if (nodesForLinking.length > maxNumVerticesSearchable) { + //Make a selection of random nodes from the array. Shift the random choices + // to the front of the array. + for (let i = 0; i < maxNumVerticesSearchable; i++) { + const oldNode = nodesForLinking[i]; + const randomIndex = Math.floor(Math.random() * (nodesForLinking.length - i)) + i; + //Swap the node positions of the randomly selected node and i + nodesForLinking[i] = nodesForLinking[randomIndex]; + nodesForLinking[randomIndex] = oldNode; } + // Trim to our random selection + nodesForLinking = nodesForLinking.slice(0, maxNumVerticesSearchable - 1); + } - // Create our query/aggregation request using the selected nodes. - // Filters are named after the index of the node in the nodesForLinking - // array. The result bucket describing the relationship between - // the first 2 nodes in the array will therefore be labelled "0|1" - const shoulds = []; - const filterMap = {}; - nodesForLinking.forEach(function(node, nodeNum) { - const nodeQuery = self.buildNodeQuery(node); - shoulds.push(nodeQuery); - filterMap[nodeNum] = nodeQuery; - }); - const searchReq = { - size: 0, - query: { - bool: { - // Only match docs that share 2 nodes so can help describe their relationship - minimum_should_match: 2, - should: shoulds, - }, + // Create our query/aggregation request using the selected nodes. + // Filters are named after the index of the node in the nodesForLinking + // array. The result bucket describing the relationship between + // the first 2 nodes in the array will therefore be labelled "0|1" + const shoulds = []; + const filterMap = {}; + nodesForLinking.forEach(function(node, nodeNum) { + const nodeQuery = self.buildNodeQuery(node); + shoulds.push(nodeQuery); + filterMap[nodeNum] = nodeQuery; + }); + const searchReq = { + size: 0, + query: { + bool: { + // Only match docs that share 2 nodes so can help describe their relationship + minimum_should_match: 2, + should: shoulds, }, - aggs: { - matrix: { - adjacency_matrix: { - separator: '|', - filters: filterMap, - }, + }, + aggs: { + matrix: { + adjacency_matrix: { + separator: '|', + filters: filterMap, }, }, - }; + }, + }; - // Search for connections between the selected nodes. - searcher(self.options.indexName, searchReq, function(data) { - const numDocsMatched = data.hits.total; - const buckets = data.aggregations.matrix.buckets; - const vertices = nodesForLinking.map(function(existingNode) { - return { - field: existingNode.data.field, - term: existingNode.data.term, - weight: 1, - depth: 0, - }; - }); + // Search for connections between the selected nodes. + searcher(self.options.indexName, searchReq, function(data) { + const numDocsMatched = data.hits.total; + const buckets = data.aggregations.matrix.buckets; + const vertices = nodesForLinking.map(function(existingNode) { + return { + field: existingNode.data.field, + term: existingNode.data.term, + weight: 1, + depth: 0, + }; + }); - let connections = []; - let maxEdgeWeight = 0; - // Turn matrix array of results into a map - const keyedBuckets = {}; - buckets.forEach(function(bucket) { - keyedBuckets[bucket.key] = bucket; - }); + let connections = []; + let maxEdgeWeight = 0; + // Turn matrix array of results into a map + const keyedBuckets = {}; + buckets.forEach(function(bucket) { + keyedBuckets[bucket.key] = bucket; + }); - buckets.forEach(function(bucket) { - // We calibrate line thickness based on % of max weight of - // all edges (including the edges we may already have in the workspace) - const ids = bucket.key.split('|'); - if (ids.length === 2) { - // bucket represents an edge - if (self.options.exploreControls.useSignificance) { - const t1 = keyedBuckets[ids[0]].doc_count; - const t2 = keyedBuckets[ids[1]].doc_count; - const t1AndT2 = bucket.doc_count; - // Calc the significant_terms score to prioritize selection of interesting links - bucket.weight = self.jLHScore( - t1AndT2, - Math.max(t1, t2), - Math.min(t1, t2), - numDocsMatched - ); - } else { - // prioritize links purely on volume of intersecting docs - bucket.weight = bucket.doc_count; - } - maxEdgeWeight = Math.max(maxEdgeWeight, bucket.weight); - } - }); - const backFilledMinLineSize = 2; - const backFilledMaxLineSize = 5; - buckets.forEach(function(bucket) { - if (bucket.doc_count < parseInt(self.options.exploreControls.minDocCount)) { - return; - } - const ids = bucket.key.split('|'); - if (ids.length === 2) { - // Bucket represents an edge - const srcNode = nodesForLinking[ids[0]]; - const targetNode = nodesForLinking[ids[1]]; - const edgeId = self.makeEdgeId(srcNode.id, targetNode.id); - const existingEdge = self.edgesMap[edgeId]; - if (existingEdge) { - // Tweak the doc_count score having just looked it up. - existingEdge.doc_count = Math.max(existingEdge.doc_count, bucket.doc_count); - } else { - connections.push({ - // source and target values are indexes into the vertices array - source: parseInt(ids[0]), - target: parseInt(ids[1]), - weight: bucket.weight, - width: Math.max( - backFilledMinLineSize, - (bucket.weight / maxEdgeWeight) * backFilledMaxLineSize - ), - doc_count: bucket.doc_count, - }); - } + buckets.forEach(function(bucket) { + // We calibrate line thickness based on % of max weight of + // all edges (including the edges we may already have in the workspace) + const ids = bucket.key.split('|'); + if (ids.length === 2) { + // bucket represents an edge + if (self.options.exploreControls.useSignificance) { + const t1 = keyedBuckets[ids[0]].doc_count; + const t2 = keyedBuckets[ids[1]].doc_count; + const t1AndT2 = bucket.doc_count; + // Calc the significant_terms score to prioritize selection of interesting links + bucket.weight = self.jLHScore( + t1AndT2, + Math.max(t1, t2), + Math.min(t1, t2), + numDocsMatched + ); + } else { + // prioritize links purely on volume of intersecting docs + bucket.weight = bucket.doc_count; } - }); - // Trim the array of connections so that we don't add too many at once - disorientating for users otherwise - if (connections.length > maxNewEdges) { - connections = connections.sort(function(a, b) { - return b.weight - a.weight; - }); - connections = connections.slice(0, maxNewEdges); + maxEdgeWeight = Math.max(maxEdgeWeight, bucket.weight); } - - // Merge the new edges into the existing workspace's graph. - // We reuse the mergeGraph function used to handle the - // results of other calls to the server-side Graph API - // so must package the results here with that same format - // even though we know all the vertices we provide will - // be duplicates and ignored. - self.mergeGraph({ - nodes: vertices, - edges: connections, - }); }); - }; - - // Provide a "fuzzy find similar" query that can find similar docs but preferably - // not re-iterating the exact terms we already have in the workspace. - // We use a free-text search on the index's configured default field (typically '_all') - // to drill-down into docs that should be linked but aren't via the exact terms - // we have in the workspace - this.getLikeThisButNotThisQuery = function(startNodes) { - const likeQueries = []; - - const txtsByFieldType = {}; - startNodes.forEach(node => { - let txt = txtsByFieldType[node.data.field]; - if (txt) { - txt = txt + ' ' + node.label; - } else { - txt = node.label; + const backFilledMinLineSize = 2; + const backFilledMaxLineSize = 5; + buckets.forEach(function(bucket) { + if (bucket.doc_count < parseInt(self.options.exploreControls.minDocCount)) { + return; } - txtsByFieldType[node.data.field] = txt; - }); - for (const field in txtsByFieldType) { - if (txtsByFieldType.hasOwnProperty(field)) { - likeQueries.push({ - more_like_this: { - like: txtsByFieldType[field], - min_term_freq: 1, - minimum_should_match: '20%', - min_doc_freq: 1, - boost_terms: 2, - max_query_terms: 25, - }, - }); + const ids = bucket.key.split('|'); + if (ids.length === 2) { + // Bucket represents an edge + const srcNode = nodesForLinking[ids[0]]; + const targetNode = nodesForLinking[ids[1]]; + const edgeId = self.makeEdgeId(srcNode.id, targetNode.id); + const existingEdge = self.edgesMap[edgeId]; + if (existingEdge) { + // Tweak the doc_count score having just looked it up. + existingEdge.doc_count = Math.max(existingEdge.doc_count, bucket.doc_count); + } else { + connections.push({ + // source and target values are indexes into the vertices array + source: parseInt(ids[0]), + target: parseInt(ids[1]), + weight: bucket.weight, + width: Math.max( + backFilledMinLineSize, + (bucket.weight / maxEdgeWeight) * backFilledMaxLineSize + ), + doc_count: bucket.doc_count, + }); + } } + }); + // Trim the array of connections so that we don't add too many at once - disorientating for users otherwise + if (connections.length > maxNewEdges) { + connections = connections.sort(function(a, b) { + return b.weight - a.weight; + }); + connections = connections.slice(0, maxNewEdges); } - const excludeNodesByField = {}; - const allExistingNodes = self.nodes; - allExistingNodes.forEach(existingNode => { - addTermToFieldList(excludeNodesByField, existingNode.data.field, existingNode.data.term); - }); - const blacklistedNodes = self.blacklistedNodes; - blacklistedNodes.forEach(blacklistedNode => { - addTermToFieldList( - excludeNodesByField, - blacklistedNode.data.field, - blacklistedNode.data.term - ); + // Merge the new edges into the existing workspace's graph. + // We reuse the mergeGraph function used to handle the + // results of other calls to the server-side Graph API + // so must package the results here with that same format + // even though we know all the vertices we provide will + // be duplicates and ignored. + self.mergeGraph({ + nodes: vertices, + edges: connections, }); + }); + }; - //Create negative boosting queries to avoid matching what you already have in the workspace. - const notExistingNodes = []; - Object.keys(excludeNodesByField).forEach(fieldName => { - const termsQuery = {}; - termsQuery[fieldName] = excludeNodesByField[fieldName]; - notExistingNodes.push({ - terms: termsQuery, + // Provide a "fuzzy find similar" query that can find similar docs but preferably + // not re-iterating the exact terms we already have in the workspace. + // We use a free-text search on the index's configured default field (typically '_all') + // to drill-down into docs that should be linked but aren't via the exact terms + // we have in the workspace + this.getLikeThisButNotThisQuery = function(startNodes) { + const likeQueries = []; + + const txtsByFieldType = {}; + startNodes.forEach(node => { + let txt = txtsByFieldType[node.data.field]; + if (txt) { + txt = txt + ' ' + node.label; + } else { + txt = node.label; + } + txtsByFieldType[node.data.field] = txt; + }); + for (const field in txtsByFieldType) { + if (txtsByFieldType.hasOwnProperty(field)) { + likeQueries.push({ + more_like_this: { + like: txtsByFieldType[field], + min_term_freq: 1, + minimum_should_match: '20%', + min_doc_freq: 1, + boost_terms: 2, + max_query_terms: 25, + }, }); + } + } + + const excludeNodesByField = {}; + const allExistingNodes = self.nodes; + allExistingNodes.forEach(existingNode => { + addTermToFieldList(excludeNodesByField, existingNode.data.field, existingNode.data.term); + }); + const blacklistedNodes = self.blacklistedNodes; + blacklistedNodes.forEach(blacklistedNode => { + addTermToFieldList( + excludeNodesByField, + blacklistedNode.data.field, + blacklistedNode.data.term + ); + }); + + //Create negative boosting queries to avoid matching what you already have in the workspace. + const notExistingNodes = []; + Object.keys(excludeNodesByField).forEach(fieldName => { + const termsQuery = {}; + termsQuery[fieldName] = excludeNodesByField[fieldName]; + notExistingNodes.push({ + terms: termsQuery, }); + }); - const result = { - // Use a boosting query to effectively to request "similar to these IDS/labels but - // preferably not containing these exact IDs". - boosting: { - negative_boost: 0.0001, - negative: { - bool: { - should: notExistingNodes, - }, + const result = { + // Use a boosting query to effectively to request "similar to these IDS/labels but + // preferably not containing these exact IDs". + boosting: { + negative_boost: 0.0001, + negative: { + bool: { + should: notExistingNodes, }, - positive: { - bool: { - should: likeQueries, - }, + }, + positive: { + bool: { + should: likeQueries, }, }, - }; - return result; + }, }; + return result; + }; - this.getSelectedIntersections = function(callback) { - if (self.selectedNodes.length === 0) { - return self.getAllIntersections(callback, self.nodes); - } - if (self.selectedNodes.length === 1) { - const selectedNode = self.selectedNodes[0]; - const neighbourNodes = self.getNeighbours(selectedNode); - neighbourNodes.push(selectedNode); - return self.getAllIntersections(callback, neighbourNodes); - } - return self.getAllIntersections(callback, self.getAllSelectedNodes()); - }; + this.getSelectedIntersections = function(callback) { + if (self.selectedNodes.length === 0) { + return self.getAllIntersections(callback, self.nodes); + } + if (self.selectedNodes.length === 1) { + const selectedNode = self.selectedNodes[0]; + const neighbourNodes = self.getNeighbours(selectedNode); + neighbourNodes.push(selectedNode); + return self.getAllIntersections(callback, neighbourNodes); + } + return self.getAllIntersections(callback, self.getAllSelectedNodes()); + }; - this.jLHScore = function(subsetFreq, subsetSize, supersetFreq, supersetSize) { - const subsetProbability = subsetFreq / subsetSize; - const supersetProbability = supersetFreq / supersetSize; + this.jLHScore = function(subsetFreq, subsetSize, supersetFreq, supersetSize) { + const subsetProbability = subsetFreq / subsetSize; + const supersetProbability = supersetFreq / supersetSize; - const absoluteProbabilityChange = subsetProbability - supersetProbability; - if (absoluteProbabilityChange <= 0) { - return 0; - } - const relativeProbabilityChange = subsetProbability / supersetProbability; - return absoluteProbabilityChange * relativeProbabilityChange; - }; + const absoluteProbabilityChange = subsetProbability - supersetProbability; + if (absoluteProbabilityChange <= 0) { + return 0; + } + const relativeProbabilityChange = subsetProbability / supersetProbability; + return absoluteProbabilityChange * relativeProbabilityChange; + }; - // Currently unused in the Kibana UI. It was a utility that provided a sorted list - // of recommended node merges for a selection of nodes. Top results would be - // rare nodes that ALWAYS appear alongside more popular ones e.g. text:9200 always - // appears alongside hashtag:elasticsearch so would be offered as a likely candidate - // for merging. - - // Determines union/intersection stats for neighbours of a node. - // TODO - could move server-side as a graph API function? - this.getAllIntersections = function(callback, nodes) { - //Ensure these are all top-level nodes only - nodes = nodes.filter(function(n) { - return n.parent === undefined; - }); + // Currently unused in the Kibana UI. It was a utility that provided a sorted list + // of recommended node merges for a selection of nodes. Top results would be + // rare nodes that ALWAYS appear alongside more popular ones e.g. text:9200 always + // appears alongside hashtag:elasticsearch so would be offered as a likely candidate + // for merging. + + // Determines union/intersection stats for neighbours of a node. + // TODO - could move server-side as a graph API function? + this.getAllIntersections = function(callback, nodes) { + //Ensure these are all top-level nodes only + nodes = nodes.filter(function(n) { + return n.parent === undefined; + }); - const allQueries = nodes.map(function(node) { - return self.buildNodeQuery(node); - }); + const allQueries = nodes.map(function(node) { + return self.buildNodeQuery(node); + }); - const allQuery = { - bool: { - should: allQueries, + const allQuery = { + bool: { + should: allQueries, + }, + }; + //==================== + const request = { + query: allQuery, + size: 0, + aggs: { + all: { + global: {}, }, - }; - //==================== - const request = { - query: allQuery, - size: 0, - aggs: { - all: { - global: {}, + sources: { + // Could use significant_terms not filters to get stats but + // for the fact some of the nodes are groups of terms. + filters: { + filters: {}, }, - sources: { - // Could use significant_terms not filters to get stats but - // for the fact some of the nodes are groups of terms. - filters: { - filters: {}, - }, - aggs: { - targets: { - filters: { - filters: {}, - }, + aggs: { + targets: { + filters: { + filters: {}, }, }, }, }, - }; - allQueries.forEach((query, n) => { - // Add aggs to get intersection stats with root node. - request.aggs.sources.filters.filters['bg' + n] = query; - request.aggs.sources.aggs.targets.filters.filters['fg' + n] = query; + }, + }; + allQueries.forEach((query, n) => { + // Add aggs to get intersection stats with root node. + request.aggs.sources.filters.filters['bg' + n] = query; + request.aggs.sources.aggs.targets.filters.filters['fg' + n] = query; + }); + searcher(self.options.indexName, request, function(data) { + const termIntersects = []; + const fullDocCounts = []; + const allDocCount = data.aggregations.all.doc_count; + + // Gather the background stats for all nodes. + nodes.forEach((rootNode, n) => { + fullDocCounts.push(data.aggregations.sources.buckets['bg' + n].doc_count); }); - searcher(self.options.indexName, request, function(data) { - const termIntersects = []; - const fullDocCounts = []; - const allDocCount = data.aggregations.all.doc_count; - - // Gather the background stats for all nodes. - nodes.forEach((rootNode, n) => { - fullDocCounts.push(data.aggregations.sources.buckets['bg' + n].doc_count); - }); - nodes.forEach((rootNode, n) => { - const t1 = fullDocCounts[n]; - const baseAgg = data.aggregations.sources.buckets['bg' + n].targets.buckets; - nodes.forEach((leafNode, l) => { - const t2 = fullDocCounts[l]; - if (l === n) { - return; - } - if (t1 > t2) { + nodes.forEach((rootNode, n) => { + const t1 = fullDocCounts[n]; + const baseAgg = data.aggregations.sources.buckets['bg' + n].targets.buckets; + nodes.forEach((leafNode, l) => { + const t2 = fullDocCounts[l]; + if (l === n) { + return; + } + if (t1 > t2) { + // We should get the same stats for t2->t1 from the t1->t2 bucket path + return; + } + if (t1 === t2) { + if (rootNode.id > leafNode.id) { // We should get the same stats for t2->t1 from the t1->t2 bucket path return; } - if (t1 === t2) { - if (rootNode.id > leafNode.id) { - // We should get the same stats for t2->t1 from the t1->t2 bucket path - return; - } - } - const t1AndT2 = baseAgg['fg' + l].doc_count; - if (t1AndT2 === 0) { - return; - } - const neighbourNode = nodes[l]; - let t1Label = rootNode.data.label; - if (rootNode.numChildren > 0) { - t1Label += '(+' + rootNode.numChildren + ')'; - } - let t2Label = neighbourNode.data.label; - if (neighbourNode.numChildren > 0) { - t2Label += '(+' + neighbourNode.numChildren + ')'; - } - - // A straight percentage can be poor if t1==1 (100%) - not too much strength of evidence - // var mergeConfidence=t1AndT2/t1; - - // So using Significance heuristic instead - const mergeConfidence = self.jLHScore(t1AndT2, t2, t1, allDocCount); - - const termIntersect = { - id1: rootNode.id, - id2: neighbourNode.id, - term1: t1Label, - term2: t2Label, - v1: t1, - v2: t2, - mergeLeftConfidence: t1AndT2 / t1, - mergeRightConfidence: t1AndT2 / t2, - mergeConfidence: mergeConfidence, - overlap: t1AndT2, - }; - termIntersects.push(termIntersect); - }); - }); - termIntersects.sort(function(a, b) { - if (b.mergeConfidence !== a.mergeConfidence) { - return b.mergeConfidence - a.mergeConfidence; } - // If of equal similarity use the size of the overlap as - // a measure of magnitude/significance for tie-breaker. - - if (b.overlap !== a.overlap) { - return b.overlap - a.overlap; + const t1AndT2 = baseAgg['fg' + l].doc_count; + if (t1AndT2 === 0) { + return; + } + const neighbourNode = nodes[l]; + let t1Label = rootNode.data.label; + if (rootNode.numChildren > 0) { + t1Label += '(+' + rootNode.numChildren + ')'; } - //All other things being equal we now favour where t2 NOT t1 is small. - return a.v2 - b.v2; + let t2Label = neighbourNode.data.label; + if (neighbourNode.numChildren > 0) { + t2Label += '(+' + neighbourNode.numChildren + ')'; + } + + // A straight percentage can be poor if t1==1 (100%) - not too much strength of evidence + // var mergeConfidence=t1AndT2/t1; + + // So using Significance heuristic instead + const mergeConfidence = self.jLHScore(t1AndT2, t2, t1, allDocCount); + + const termIntersect = { + id1: rootNode.id, + id2: neighbourNode.id, + term1: t1Label, + term2: t2Label, + v1: t1, + v2: t2, + mergeLeftConfidence: t1AndT2 / t1, + mergeRightConfidence: t1AndT2 / t2, + mergeConfidence: mergeConfidence, + overlap: t1AndT2, + }; + termIntersects.push(termIntersect); }); - if (callback) { - callback(termIntersects); + }); + termIntersects.sort(function(a, b) { + if (b.mergeConfidence !== a.mergeConfidence) { + return b.mergeConfidence - a.mergeConfidence; + } + // If of equal similarity use the size of the overlap as + // a measure of magnitude/significance for tie-breaker. + + if (b.overlap !== a.overlap) { + return b.overlap - a.overlap; } + //All other things being equal we now favour where t2 NOT t1 is small. + return a.v2 - b.v2; }); - }; + if (callback) { + callback(termIntersects); + } + }); + }; - // Internal utility function for calling the Graph API and handling the response - // by merging results into existing nodes in this workspace. - this.callElasticsearch = function(request) { - self.lastRequest = JSON.stringify(request, null, '\t'); - graphExplorer(self.options.indexName, request, function(data) { - self.lastResponse = JSON.stringify(data, null, '\t'); - const edges = []; - //Label the nodes with field number for CSS styling - data.vertices.forEach(node => { - self.options.vertex_fields.some(fieldDef => { - if (node.field === fieldDef.name) { - node.color = fieldDef.color; - node.icon = fieldDef.icon; - node.fieldDef = fieldDef; - return true; - } - return false; - }); + // Internal utility function for calling the Graph API and handling the response + // by merging results into existing nodes in this workspace. + this.callElasticsearch = function(request) { + self.lastRequest = JSON.stringify(request, null, '\t'); + graphExplorer(self.options.indexName, request, function(data) { + self.lastResponse = JSON.stringify(data, null, '\t'); + const edges = []; + //Label the nodes with field number for CSS styling + data.vertices.forEach(node => { + self.options.vertex_fields.some(fieldDef => { + if (node.field === fieldDef.name) { + node.color = fieldDef.color; + node.icon = fieldDef.icon; + node.fieldDef = fieldDef; + return true; + } + return false; }); + }); - //Size the edges depending on weight - const minLineSize = 2; - const maxLineSize = 10; - let maxEdgeWeight = 0.00000001; - data.connections.forEach(edge => { - maxEdgeWeight = Math.max(maxEdgeWeight, edge.weight); - }); - data.connections.forEach(edge => { - edges.push({ - source: edge.source, - target: edge.target, - doc_count: edge.doc_count, - weight: edge.weight, - width: Math.max(minLineSize, (edge.weight / maxEdgeWeight) * maxLineSize), - }); + //Size the edges depending on weight + const minLineSize = 2; + const maxLineSize = 10; + let maxEdgeWeight = 0.00000001; + data.connections.forEach(edge => { + maxEdgeWeight = Math.max(maxEdgeWeight, edge.weight); + }); + data.connections.forEach(edge => { + edges.push({ + source: edge.source, + target: edge.target, + doc_count: edge.doc_count, + weight: edge.weight, + width: Math.max(minLineSize, (edge.weight / maxEdgeWeight) * maxLineSize), }); - - self.mergeGraph( - { - nodes: data.vertices, - edges: edges, - }, - { - labeller: self.options.labeller, - } - ); }); - }; - } - //===================== - // Begin Kibana wrapper - return { - createWorkspace: createWorkspace, + self.mergeGraph( + { + nodes: data.vertices, + edges: edges, + }, + { + labeller: self.options.labeller, + } + ); + }); }; -})(); +} +//===================== + +// Begin Kibana wrapper +export function createWorkspace(options) { + return new GraphWorkspace(options); +} diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js b/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js index 6f81a443086c0..7ffb16d986a21 100644 --- a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js +++ b/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import gws from './graph_client_workspace'; +import { createWorkspace } from './graph_client_workspace'; describe('graphui-workspace', function() { describe('createWorkspace()', function() { @@ -38,7 +38,7 @@ describe('graphui-workspace', function() { minDocCount: 1, }, }; - const workspace = gws.createWorkspace(options); + const workspace = createWorkspace(options); return { workspace, //, get to(){} diff --git a/x-pack/plugins/graph/public/angular/templates/index.html b/x-pack/plugins/graph/public/angular/templates/index.html index 4493d794cb8d1..8555658596179 100644 --- a/x-pack/plugins/graph/public/angular/templates/index.html +++ b/x-pack/plugins/graph/public/angular/templates/index.html @@ -208,7 +208,7 @@ ng-click="selectSelected(n)"> + ng-click="workspace.deselectNode(n)" > +> diff --git a/x-pack/plugins/graph/public/app.js b/x-pack/plugins/graph/public/app.js index 53175d18e629f..7effe44375b1f 100644 --- a/x-pack/plugins/graph/public/app.js +++ b/x-pack/plugins/graph/public/app.js @@ -23,7 +23,7 @@ import { Listing } from './components/listing'; import { Settings } from './components/settings'; import { GraphVisualization } from './components/graph_visualization'; -import gws from './angular/graph_client_workspace.js'; +import { createWorkspace } from './angular/graph_client_workspace.js'; import { getEditUrl, getNewPath, getEditPath, setBreadcrumbs } from './services/url'; import { createCachedIndexPatternProvider } from './services/index_pattern_cache'; import { urlTemplateRegex } from './helpers/url_template'; @@ -277,7 +277,7 @@ export function initGraphApp(angularModule, deps) { searchProxy: callSearchNodeProxy, exploreControls, }; - $scope.workspace = gws.createWorkspace(options); + $scope.workspace = createWorkspace(options); }, setLiveResponseFields: fields => { $scope.liveResponseFields = fields; diff --git a/x-pack/plugins/graph/public/plugin.ts b/x-pack/plugins/graph/public/plugin.ts index 5521de705b6ec..4593ad9ba2613 100644 --- a/x-pack/plugins/graph/public/plugin.ts +++ b/x-pack/plugins/graph/public/plugin.ts @@ -21,7 +21,7 @@ import { FeatureCatalogueCategory, HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { ConfigSchema } from '../config'; export interface GraphPluginSetupDependencies { diff --git a/x-pack/plugins/graph/public/state_management/url_templates.ts b/x-pack/plugins/graph/public/state_management/url_templates.ts index a0fb9503421a4..1701fc244ab52 100644 --- a/x-pack/plugins/graph/public/state_management/url_templates.ts +++ b/x-pack/plugins/graph/public/state_management/url_templates.ts @@ -17,7 +17,7 @@ import { setDatasource, IndexpatternDatasource, requestDatasource } from './data import { outlinkEncoders } from '../helpers/outlink_encoders'; import { urlTemplatePlaceholder } from '../helpers/url_template'; import { matchesOne } from './helpers'; -import { modifyUrl } from '../../../../../src/core/utils'; +import { modifyUrl } from '../../../../../src/core/public'; const actionCreator = actionCreatorFactory('x-pack/graph/urlTemplates'); diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts index eb5b0bdbcfbc5..07d5fe59a9718 100644 --- a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts @@ -14,6 +14,8 @@ export const METRIC_EXPLORER_AGGREGATIONS = [ 'rate', 'count', 'sum', + 'p95', + 'p99', ] as const; type MetricExplorerAggregations = typeof METRIC_EXPLORER_AGGREGATIONS[number]; diff --git a/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/rx.ts b/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/rx.ts index 6843f6149c711..76d61e3b94441 100644 --- a/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/rx.ts +++ b/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/rx.ts @@ -7,6 +7,6 @@ import { networkTrafficWithInterfaces } from '../../../shared/metrics/snapshot/network_traffic_with_interfaces'; export const rx = networkTrafficWithInterfaces( 'rx', - 'docker.network.in.bytes', + 'docker.network.inbound.bytes', 'docker.network.interface' ); diff --git a/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/tx.ts b/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/tx.ts index bccb4e60e9d19..4f413ed350f40 100644 --- a/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/tx.ts +++ b/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/tx.ts @@ -7,6 +7,6 @@ import { networkTrafficWithInterfaces } from '../../../shared/metrics/snapshot/network_traffic_with_interfaces'; export const tx = networkTrafficWithInterfaces( 'tx', - 'docker.network.out.bytes', + 'docker.network.outbound.bytes', 'docker.network.interface' ); diff --git a/x-pack/plugins/infra/common/inventory_models/container/metrics/tsvb/container_network_traffic.ts b/x-pack/plugins/infra/common/inventory_models/container/metrics/tsvb/container_network_traffic.ts index 18daac446bdcd..0300b9deee115 100644 --- a/x-pack/plugins/infra/common/inventory_models/container/metrics/tsvb/container_network_traffic.ts +++ b/x-pack/plugins/infra/common/inventory_models/container/metrics/tsvb/container_network_traffic.ts @@ -20,37 +20,73 @@ export const containerNetworkTraffic: TSVBMetricModelCreator = ( series: [ { id: 'tx', - split_mode: 'everything', metrics: [ { - field: 'docker.network.out.bytes', - id: 'avg-network-out', - type: 'avg', + field: 'docker.network.outbound.bytes', + id: 'max-net-out', + type: 'max', + }, + { + field: 'max-net-out', + id: 'deriv-max-net-out', + type: 'derivative', + unit: '1s', + }, + { + id: 'posonly-deriv-max-net-out', + type: 'calculation', + variables: [{ id: 'var-rate', name: 'rate', field: 'deriv-max-net-out' }], + script: 'params.rate > 0.0 ? params.rate : 0.0', + }, + { + function: 'sum', + id: 'seriesagg-sum', + type: 'series_agg', }, ], + split_mode: 'terms', + terms_field: 'docker.network.interface', }, { id: 'rx', - split_mode: 'everything', metrics: [ { - field: 'docker.network.in.bytes', - id: 'avg-network-in', - type: 'avg', + field: 'docker.network.inbound.bytes', + id: 'max-net-in', + type: 'max', }, { - id: 'invert-posonly-deriv-max-network-in', + field: 'max-net-in', + id: 'deriv-max-net-in', + type: 'derivative', + unit: '1s', + }, + { + id: 'posonly-deriv-max-net-in', + type: 'calculation', + variables: [{ id: 'var-rate', name: 'rate', field: 'deriv-max-net-in' }], + script: 'params.rate > 0.0 ? params.rate : 0.0', + }, + { + id: 'calc-invert-rate', script: 'params.rate * -1', type: 'calculation', variables: [ { - field: 'avg-network-in', + field: 'posonly-deriv-max-net-in', id: 'var-rate', name: 'rate', }, ], }, + { + function: 'sum', + id: 'seriesagg-sum', + type: 'series_agg', + }, ], + split_mode: 'terms', + terms_field: 'docker.network.interface', }, ], }); diff --git a/x-pack/plugins/infra/common/inventory_models/types.ts b/x-pack/plugins/infra/common/inventory_models/types.ts index a6773d0a07450..35d83440812d5 100644 --- a/x-pack/plugins/infra/common/inventory_models/types.ts +++ b/x-pack/plugins/infra/common/inventory_models/types.ts @@ -152,11 +152,26 @@ export const TSVBMetricModelSeriesAggRT = rt.type({ type: rt.literal('series_agg'), }); +export const TSVBPercentileItemRT = rt.type({ + id: rt.string, + value: rt.number, +}); + +export const TSVBMetricModePercentileAggRT = rt.intersection([ + rt.type({ + id: rt.string, + type: rt.literal('percentile'), + percentiles: rt.array(TSVBPercentileItemRT), + }), + rt.partial({ field: rt.string }), +]); + export const TSVBMetricRT = rt.union([ TSVBMetricModelCountRT, TSVBMetricModelBasicMetricRT, TSVBMetricModelBucketScriptRT, TSVBMetricModelDerivativeRT, + TSVBMetricModePercentileAggRT, TSVBMetricModelSeriesAggRT, ]); export type TSVBMetric = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/saved_objects/inventory_view.ts b/x-pack/plugins/infra/common/saved_objects/inventory_view.ts index 8933de57b0448..c14b75efc6887 100644 --- a/x-pack/plugins/infra/common/saved_objects/inventory_view.ts +++ b/x-pack/plugins/infra/common/saved_objects/inventory_view.ts @@ -5,18 +5,18 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ElasticsearchMappingOf } from '../../server/utils/typed_elasticsearch_mappings'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { WaffleViewState } from '../../public/pages/metrics/inventory_view/hooks/use_waffle_view_state'; +import { SavedObjectsType } from 'src/core/server'; -export const inventoryViewSavedObjectType = 'inventory-view'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedViewSavedObject } from '../../public/hooks/use_saved_view'; +export const inventoryViewSavedObjectName = 'inventory-view'; -export const inventoryViewSavedObjectMappings: { - [inventoryViewSavedObjectType]: ElasticsearchMappingOf>; -} = { - [inventoryViewSavedObjectType]: { +export const inventoryViewSavedObjectType: SavedObjectsType = { + name: inventoryViewSavedObjectName, + hidden: false, + namespaceType: 'single', + management: { + importableAndExportable: true, + }, + mappings: { properties: { name: { type: 'keyword', diff --git a/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts b/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts index add6ab0f132b5..88bbc945e32dc 100644 --- a/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts +++ b/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts @@ -5,30 +5,18 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ElasticsearchMappingOf } from '../../server/utils/typed_elasticsearch_mappings'; -import { - MetricsExplorerOptions, - MetricsExplorerChartOptions, - MetricsExplorerTimeOptions, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedViewSavedObject } from '../../public/hooks/use_saved_view'; - -interface MetricsExplorerSavedView { - options: MetricsExplorerOptions; - chartOptions: MetricsExplorerChartOptions; - currentTimerange: MetricsExplorerTimeOptions; -} +import { SavedObjectsType } from 'src/core/server'; -export const metricsExplorerViewSavedObjectType = 'metrics-explorer-view'; +export const metricsExplorerViewSavedObjectName = 'metrics-explorer-view'; -export const metricsExplorerViewSavedObjectMappings: { - [metricsExplorerViewSavedObjectType]: ElasticsearchMappingOf< - SavedViewSavedObject - >; -} = { - [metricsExplorerViewSavedObjectType]: { +export const metricsExplorerViewSavedObjectType: SavedObjectsType = { + name: metricsExplorerViewSavedObjectName, + hidden: false, + namespaceType: 'single', + management: { + importableAndExportable: true, + }, + mappings: { properties: { name: { type: 'keyword', diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index a15465a0cde66..ea66ae7a46d4e 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -4,7 +4,6 @@ "kibanaVersion": "kibana", "requiredPlugins": [ "features", - "apm", "usageCollection", "spaces", "home", diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx index 38709c117c817..b0c8cdb9d4195 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx @@ -36,6 +36,7 @@ export const AlertFlyout = (props: Props) => { toastNotifications: services.notifications?.toasts, http: services.http, docLinks: services.docLinks, + capabilities: services.application.capabilities, actionTypeRegistry: triggersActionsUI.actionTypeRegistry, alertTypeRegistry: triggersActionsUI.alertTypeRegistry, }} diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index 5e14babddcb07..406f9c7602d35 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { debounce } from 'lodash'; import React, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react'; import { EuiSpacer, @@ -40,6 +41,8 @@ import { ExpressionRow } from './expression_row'; import { AlertContextMeta, TimeUnit, MetricExpression } from '../types'; import { ExpressionChart } from './expression_chart'; +const FILTER_TYPING_DEBOUNCE_MS = 500; + interface Props { errors: IErrorObject[]; alertParams: { @@ -125,6 +128,10 @@ export const Expressions: React.FC = props => { [setAlertParams, derivedIndexPattern] ); + const debouncedOnFilterChange = useCallback(debounce(onFilterChange, FILTER_TYPING_DEBOUNCE_MS), [ + onFilterChange, + ]); + const onGroupByChange = useCallback( (group: string | null) => { setAlertParams('groupBy', group || ''); @@ -251,7 +258,7 @@ export const Expressions: React.FC = props => { context={alertsContext} derivedIndexPattern={derivedIndexPattern} source={source} - filterQuery={alertParams.filterQuery} + filterQuery={alertParams.filterQueryText} groupBy={alertParams.groupBy} /> @@ -320,7 +327,7 @@ export const Expressions: React.FC = props => { {(alertsContext.metadata && ( diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx index 83298afd4fc5a..7e85a2bdf7e9b 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx @@ -34,6 +34,7 @@ export const AlertFlyout = (props: Props) => { toastNotifications: services.notifications?.toasts, http: services.http, docLinks: services.docLinks, + capabilities: services.application.capabilities, actionTypeRegistry: triggersActionsUI.actionTypeRegistry, alertTypeRegistry: triggersActionsUI.alertTypeRegistry, }} diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx index 15cad770836bd..c2ee552e31553 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx +++ b/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { debounce } from 'lodash'; import React, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react'; import { EuiFlexGroup, @@ -51,6 +52,8 @@ import { NodeTypeExpression } from './node_type'; import { InfraWaffleMapOptions } from '../../../lib/lib'; import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; +const FILTER_TYPING_DEBOUNCE_MS = 500; + interface AlertContextMeta { options?: Partial; nodeType?: InventoryItemType; @@ -134,6 +137,10 @@ export const Expressions: React.FC = props => { [derivedIndexPattern, setAlertParams] ); + const debouncedOnFilterChange = useCallback(debounce(onFilterChange, FILTER_TYPING_DEBOUNCE_MS), [ + onFilterChange, + ]); + const emptyError = useMemo(() => { return { aggField: [], @@ -291,7 +298,7 @@ export const Expressions: React.FC = props => { )) || ( diff --git a/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx index 37cea9314cfe8..bd889bff8cd0e 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx @@ -30,6 +30,7 @@ export const AlertFlyout = (props: Props) => { toastNotifications: services.notifications?.toasts, http: services.http, docLinks: services.docLinks, + capabilities: services.application.capabilities, actionTypeRegistry: triggersActionsUI.actionTypeRegistry, alertTypeRegistry: triggersActionsUI.alertTypeRegistry, }} diff --git a/x-pack/plugins/infra/public/components/loading_page.tsx b/x-pack/plugins/infra/public/components/loading_page.tsx index ae179c6542c13..9d37fed45b583 100644 --- a/x-pack/plugins/infra/public/components/loading_page.tsx +++ b/x-pack/plugins/infra/public/components/loading_page.tsx @@ -27,7 +27,7 @@ export const LoadingPage = ({ message }: LoadingPageProps) => ( - {message} + {message}
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/saved_views.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/saved_views.tsx index 356f0598e00d2..96271ea126046 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/saved_views.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/saved_views.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control'; -import { inventoryViewSavedObjectType } from '../../../../../common/saved_objects/inventory_view'; +import { inventoryViewSavedObjectName } from '../../../../../common/saved_objects/inventory_view'; import { useWaffleViewState } from '../hooks/use_waffle_view_state'; export const SavedViews = () => { @@ -15,7 +15,7 @@ export const SavedViews = () => { defaultViewState={defaultViewState} viewState={viewState} onViewChange={onViewChange} - viewType={inventoryViewSavedObjectType} + viewType={inventoryViewSavedObjectName} /> ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx index 5a84d204b3b25..8d397d9f96b59 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx @@ -41,6 +41,12 @@ export const MetricsExplorerAggregationPicker = ({ options, onChange }: Props) = ['rate']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.rate', { defaultMessage: 'Rate', }), + ['p95']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.p95', { + defaultMessage: '95th Percentile', + }), + ['p99']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.p99', { + defaultMessage: '99th Percentile', + }), ['count']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.count', { defaultMessage: 'Document count', }), diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domain.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domain.ts index 811486d355f2e..5cfc8c366b444 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domain.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/calculate_domain.ts @@ -6,6 +6,7 @@ import { min, max, sum, isNumber } from 'lodash'; import { MetricsExplorerSeries } from '../../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptionsMetric } from '../../hooks/use_metrics_explorer_options'; +import { getMetricId } from './get_metric_id'; const getMin = (values: Array) => { const minValue = min(values); @@ -26,7 +27,7 @@ export const calculateDomain = ( .reduce((acc, row) => { const rowValues = metrics .map((m, index) => { - return (row[`metric_${index}`] as number) || null; + return (row[getMetricId(m, index)] as number) || null; }) .filter(v => isNumber(v)); const minValue = getMin(rowValues); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/get_metric_id.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/get_metric_id.ts new file mode 100644 index 0000000000000..35ca2561b0862 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/get_metric_id.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsExplorerOptionsMetric } from '../../hooks/use_metrics_explorer_options'; + +export const getMetricId = (metric: MetricsExplorerOptionsMetric, index: string | number) => { + if (['p95', 'p99'].includes(metric.aggregation)) { + return `metric_${index}:percentile_0`; + } + return `metric_${index}`; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx index 6913f67bad08a..76945eb528345 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx @@ -24,7 +24,7 @@ import { MetricsExplorerAggregationPicker } from './aggregation'; import { MetricsExplorerChartOptions as MetricsExplorerChartOptionsComponent } from './chart_options'; import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control'; import { MetricExplorerViewState } from '../hooks/use_metric_explorer_state'; -import { metricsExplorerViewSavedObjectType } from '../../../../../common/saved_objects/metrics_explorer_view'; +import { metricsExplorerViewSavedObjectName } from '../../../../../common/saved_objects/metrics_explorer_view'; import { useKibanaUiSetting } from '../../../../utils/use_kibana_ui_setting'; import { mapKibanaQuickRangesToDatePickerRanges } from '../../../../utils/map_timepicker_quickranges_to_datepicker_ranges'; import { ToolbarPanel } from '../../../../components/toolbar_panel'; @@ -129,7 +129,7 @@ export const MetricsExplorerToolbar = ({ chartOptions, currentTimerange: timeRange, }} - viewType={metricsExplorerViewSavedObjectType} + viewType={metricsExplorerViewSavedObjectName} onViewChange={onViewStateChange} />
diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index d61ef7fc4a631..866acec5b6ffe 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -12,7 +12,7 @@ import { PluginInitializerContext, AppMountParameters, } from 'kibana/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { registerStartSingleton } from './legacy_singletons'; import { registerFeatures } from './register_feature'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; diff --git a/x-pack/plugins/infra/server/index.ts b/x-pack/plugins/infra/server/index.ts index 6cb04897af3f5..f0d417c5c311a 100644 --- a/x-pack/plugins/infra/server/index.ts +++ b/x-pack/plugins/infra/server/index.ts @@ -6,9 +6,8 @@ import { PluginInitializerContext } from 'src/core/server'; import { config, InfraConfig, InfraServerPlugin, InfraPluginSetup } from './plugin'; -import { savedObjectMappings } from './saved_objects'; -export { config, InfraConfig, savedObjectMappings, InfraPluginSetup }; +export { config, InfraConfig, InfraPluginSetup }; export function plugin(context: PluginInitializerContext) { return new InfraServerPlugin(context); diff --git a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts new file mode 100644 index 0000000000000..a2c5b27c38fd6 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEmpty } from 'lodash'; +import { schema } from '@kbn/config-schema'; + +export const oneOfLiterals = (arrayOfLiterals: Readonly) => + schema.string({ + validate: value => + arrayOfLiterals.includes(value) ? undefined : `must be one of ${arrayOfLiterals.join(' | ')}`, + }); + +export const validateIsStringElasticsearchJSONFilter = (value: string) => { + const errorMessage = 'filterQuery must be a valid Elasticsearch filter expressed in JSON'; + try { + const parsedValue = JSON.parse(value); + if (!isEmpty(parsedValue.bool)) { + return undefined; + } + return errorMessage; + } catch (e) { + return errorMessage; + } +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index 3b6a1b5557bc6..71cde0175befe 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -11,19 +11,13 @@ import { createInventoryMetricThresholdExecutor, FIRED_ACTIONS, } from './inventory_metric_threshold_executor'; -import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from './types'; +import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; import { InfraBackendLibs } from '../../infra_types'; +import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; const condition = schema.object({ threshold: schema.arrayOf(schema.number()), - comparator: schema.oneOf([ - schema.literal('>'), - schema.literal('<'), - schema.literal('>='), - schema.literal('<='), - schema.literal('between'), - schema.literal('outside'), - ]), + comparator: oneOfLiterals(Object.values(Comparator)), timeUnit: schema.string(), timeSize: schema.number(), metric: schema.string(), @@ -37,7 +31,9 @@ export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs { criteria: schema.arrayOf(condition), nodeType: schema.string(), - filterQuery: schema.maybe(schema.string()), + filterQuery: schema.maybe( + schema.string({ validate: validateIsStringElasticsearchJSONFilter }) + ), sourceId: schema.string(), }, { unknowns: 'allow' } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 5c34a058577a1..ec9389537835b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -58,18 +58,7 @@ const getParsedFilterQuery: ( filterQuery: string | undefined ) => Record | Array> = filterQuery => { if (!filterQuery) return {}; - try { - return JSON.parse(filterQuery).bool; - } catch (e) { - return [ - { - query_string: { - query: filterQuery, - analyze_wildcard: true, - }, - }, - ]; - } + return JSON.parse(filterQuery).bool; }; export const getElasticsearchMetricQuery = ( @@ -265,8 +254,8 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: s const currentValues = await getMetric( services, criterion, - config.fields.timestamp, config.metricAlias, + config.fields.timestamp, groupBy, filterQuery ); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 23611559a184f..e40cee1b9dda9 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -11,12 +11,7 @@ import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metric import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; import { InfraBackendLibs } from '../../infra_types'; - -const oneOfLiterals = (arrayOfLiterals: Readonly) => - schema.string({ - validate: value => - arrayOfLiterals.includes(value) ? undefined : `must be one of ${arrayOfLiterals.join(' | ')}`, - }); +import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { const baseCriterion = { @@ -68,7 +63,11 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { { criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])), groupBy: schema.maybe(schema.string()), - filterQuery: schema.maybe(schema.string()), + filterQuery: schema.maybe( + schema.string({ + validate: validateIsStringElasticsearchJSONFilter, + }) + ), sourceId: schema.string(), alertOnNoData: schema.maybe(schema.boolean()), }, diff --git a/x-pack/plugins/infra/server/lib/sources/index.ts b/x-pack/plugins/infra/server/lib/sources/index.ts index 9dcbe02bd064b..45348e1bfc6d8 100644 --- a/x-pack/plugins/infra/server/lib/sources/index.ts +++ b/x-pack/plugins/infra/server/lib/sources/index.ts @@ -5,6 +5,6 @@ */ export * from './defaults'; -export * from './saved_object_mappings'; +export { infraSourceConfigurationSavedObjectType } from './saved_object_type'; export * from './sources'; export * from '../../../common/http_api/source_api'; diff --git a/x-pack/plugins/infra/server/lib/sources/saved_object_mappings.ts b/x-pack/plugins/infra/server/lib/sources/saved_object_type.ts similarity index 76% rename from x-pack/plugins/infra/server/lib/sources/saved_object_mappings.ts rename to x-pack/plugins/infra/server/lib/sources/saved_object_type.ts index e5b230373b7ec..49780fc249d1f 100644 --- a/x-pack/plugins/infra/server/lib/sources/saved_object_mappings.ts +++ b/x-pack/plugins/infra/server/lib/sources/saved_object_type.ts @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; -import { InfraSavedSourceConfiguration } from '../../../common/http_api/source_api'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SavedObjectsType } from 'src/core/server'; -export const infraSourceConfigurationSavedObjectType = 'infrastructure-ui-source'; +export const infraSourceConfigurationSavedObjectName = 'infrastructure-ui-source'; -export const infraSourceConfigurationSavedObjectMappings: { - [infraSourceConfigurationSavedObjectType]: ElasticsearchMappingOf; -} = { - [infraSourceConfigurationSavedObjectType]: { +export const infraSourceConfigurationSavedObjectType: SavedObjectsType = { + name: infraSourceConfigurationSavedObjectName, + hidden: false, + namespaceType: 'single', + management: { + importableAndExportable: true, + }, + mappings: { properties: { name: { type: 'text', diff --git a/x-pack/plugins/infra/server/lib/sources/sources.ts b/x-pack/plugins/infra/server/lib/sources/sources.ts index 71682c9e798a6..50f725cc6e099 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.ts @@ -12,7 +12,7 @@ import { map, fold } from 'fp-ts/lib/Either'; import { SavedObjectsClientContract } from 'src/core/server'; import { defaultSourceConfiguration } from './defaults'; import { NotFoundError } from './errors'; -import { infraSourceConfigurationSavedObjectType } from './saved_object_mappings'; +import { infraSourceConfigurationSavedObjectName } from './saved_object_type'; import { InfraSavedSourceConfiguration, InfraSourceConfiguration, @@ -108,7 +108,7 @@ export class InfraSources { const createdSourceConfiguration = convertSavedObjectToSavedSourceConfiguration( await savedObjectsClient.create( - infraSourceConfigurationSavedObjectType, + infraSourceConfigurationSavedObjectName, pickSavedSourceConfiguration(newSourceConfiguration) as any, { id: sourceId } ) @@ -127,7 +127,7 @@ export class InfraSources { savedObjectsClient: SavedObjectsClientContract, sourceId: string ) { - await savedObjectsClient.delete(infraSourceConfigurationSavedObjectType, sourceId); + await savedObjectsClient.delete(infraSourceConfigurationSavedObjectName, sourceId); } public async updateSourceConfiguration( @@ -149,7 +149,7 @@ export class InfraSources { const updatedSourceConfiguration = convertSavedObjectToSavedSourceConfiguration( await savedObjectsClient.update( - infraSourceConfigurationSavedObjectType, + infraSourceConfigurationSavedObjectName, sourceId, pickSavedSourceConfiguration(updatedSourceConfigurationAttributes) as any, { @@ -207,7 +207,7 @@ export class InfraSources { sourceId: string ) { const savedObject = await savedObjectsClient.get( - infraSourceConfigurationSavedObjectType, + infraSourceConfigurationSavedObjectName, sourceId ); @@ -216,7 +216,7 @@ export class InfraSources { private async getAllSavedSourceConfigurations(savedObjectsClient: SavedObjectsClientContract) { const savedObjects = await savedObjectsClient.find({ - type: infraSourceConfigurationSavedObjectType, + type: infraSourceConfigurationSavedObjectName, }); return savedObjects.saved_objects.map(convertSavedObjectToSavedSourceConfiguration); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 13446594ab114..496c2b32373a8 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -28,6 +28,9 @@ import { METRICS_FEATURE, LOGS_FEATURE } from './features'; import { UsageCollector } from './usage/usage_collector'; import { InfraStaticSourceConfiguration } from '../common/http_api/source_api'; import { registerAlertTypes } from './lib/alerting'; +import { infraSourceConfigurationSavedObjectType } from './lib/sources'; +import { metricsExplorerViewSavedObjectType } from '../common/saved_objects/metrics_explorer_view'; +import { inventoryViewSavedObjectType } from '../common/saved_objects/inventory_view'; export const config = { schema: schema.object({ @@ -85,13 +88,6 @@ export class InfraServerPlugin { this.config$ = context.config.create(); } - getLibs() { - if (!this.libs) { - throw new Error('libs not set up yet'); - } - return this.libs; - } - async setup(core: CoreSetup, plugins: InfraServerPluginDeps) { await new Promise(resolve => { this.config$.subscribe(configValue => { @@ -113,6 +109,11 @@ export class InfraServerPlugin { const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); + // register saved object types + core.savedObjects.registerType(infraSourceConfigurationSavedObjectType); + core.savedObjects.registerType(metricsExplorerViewSavedObjectType); + core.savedObjects.registerType(inventoryViewSavedObjectType); + // TODO: separate these out individually and do away with "domains" as a temporary group const domainLibs: InfraDomainLibs = { fields: new InfraFieldsDomain(new FrameworkFieldsAdapter(framework), { diff --git a/x-pack/plugins/infra/server/routes/metadata/index.ts b/x-pack/plugins/infra/server/routes/metadata/index.ts index fe142aa93dcda..7e3a30e1e6918 100644 --- a/x-pack/plugins/infra/server/routes/metadata/index.ts +++ b/x-pack/plugins/infra/server/routes/metadata/index.ts @@ -18,7 +18,6 @@ import { import { InfraBackendLibs } from '../../lib/infra_types'; import { getMetricMetadata } from './lib/get_metric_metadata'; import { pickFeatureName } from './lib/pick_feature_name'; -import { hasAPMData } from './lib/has_apm_data'; import { getCloudMetricsMetadata } from './lib/get_cloud_metric_metadata'; import { getNodeInfo } from './lib/get_node_info'; import { throwErrors } from '../../../common/runtime_types'; @@ -74,16 +73,13 @@ export const initMetadataRoute = (libs: InfraBackendLibs) => { const cloudMetricsFeatures = pickFeatureName(cloudMetricsMetadata.buckets).map( nameToFeature('metrics') ); - const hasAPM = await hasAPMData(framework, requestContext, configuration, nodeId, nodeType); - const apmMetricFeatures = hasAPM ? [{ name: 'apm.transaction', source: 'apm' }] : []; - const id = metricsMetadata.id; const name = metricsMetadata.name || id; return response.ok({ body: InfraMetadataRT.encode({ id, name, - features: [...metricFeatures, ...cloudMetricsFeatures, ...apmMetricFeatures], + features: [...metricFeatures, ...cloudMetricsFeatures], info, }), }); diff --git a/x-pack/plugins/infra/server/routes/metadata/lib/has_apm_data.ts b/x-pack/plugins/infra/server/routes/metadata/lib/has_apm_data.ts deleted file mode 100644 index 1f8029db80d86..0000000000000 --- a/x-pack/plugins/infra/server/routes/metadata/lib/has_apm_data.ts +++ /dev/null @@ -1,54 +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 { RequestHandlerContext } from 'src/core/server'; - -import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter'; -import { InfraSourceConfiguration } from '../../../lib/sources'; -import { findInventoryFields } from '../../../../common/inventory_models'; -import { InventoryItemType } from '../../../../common/inventory_models/types'; - -export const hasAPMData = async ( - framework: KibanaFramework, - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - nodeId: string, - nodeType: InventoryItemType -) => { - const apmIndices = await framework.plugins.apm.getApmIndices(); - const apmIndex = apmIndices['apm_oss.transactionIndices'] || 'apm-*'; - const fields = findInventoryFields(nodeType, sourceConfiguration.fields); - - // There is a bug in APM ECS data where host.name is not set. - // This will fixed with: https://github.com/elastic/apm-server/issues/2502 - const nodeFieldName = nodeType === 'host' ? 'host.hostname' : fields.id; - const params = { - allowNoIndices: true, - ignoreUnavailable: true, - terminateAfter: 1, - index: apmIndex, - body: { - size: 0, - query: { - bool: { - filter: [ - { - match: { [nodeFieldName]: nodeId }, - }, - { - exists: { field: 'service.name' }, - }, - { - exists: { field: 'transaction.type' }, - }, - ], - }, - }, - }, - }; - const response = await framework.callWithRequest<{}, {}>(requestContext, 'search', params); - return response.hits.total.value !== 0; -}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts index c537ba0d5163e..a7f393261a096 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts @@ -4,9 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { InfraMetricModelMetricType } from '../../../lib/adapters/metrics'; import { MetricsExplorerRequestBody } from '../../../../common/http_api/metrics_explorer'; import { TSVBMetricModel } from '../../../../common/inventory_models/types'; + +const percentileToVaue = (agg: 'p95' | 'p99') => { + if (agg === 'p95') { + return 95; + } + return 99; +}; + export const createMetricModel = (options: MetricsExplorerRequestBody): TSVBMetricModel => { return { id: 'custom', @@ -47,6 +54,27 @@ export const createMetricModel = (options: MetricsExplorerRequestBody): TSVBMetr ], }; } + + if (metric.aggregation === 'p95' || metric.aggregation === 'p99') { + return { + id: `metric_${index}`, + split_mode: 'everything', + metrics: [ + { + field: metric.field, + id: `metric_${metric.aggregation}_${index}`, + type: 'percentile', + percentiles: [ + { + id: 'percentile_0', + value: percentileToVaue(metric.aggregation), + }, + ], + }, + ], + }; + } + // Create a basic TSVB series with a single metric const aggregation = metric.aggregation || 'avg'; @@ -57,7 +85,7 @@ export const createMetricModel = (options: MetricsExplorerRequestBody): TSVBMetr { field: metric.field, id: `metric_${aggregation}_${index}`, - type: InfraMetricModelMetricType[aggregation], + type: aggregation, }, ], }; diff --git a/x-pack/plugins/infra/server/saved_objects.ts b/x-pack/plugins/infra/server/saved_objects.ts deleted file mode 100644 index 2e554300b0ecb..0000000000000 --- a/x-pack/plugins/infra/server/saved_objects.ts +++ /dev/null @@ -1,15 +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 { infraSourceConfigurationSavedObjectMappings } from './lib/sources'; -import { metricsExplorerViewSavedObjectMappings } from '../common/saved_objects/metrics_explorer_view'; -import { inventoryViewSavedObjectMappings } from '../common/saved_objects/inventory_view'; - -export const savedObjectMappings = { - ...infraSourceConfigurationSavedObjectMappings, - ...metricsExplorerViewSavedObjectMappings, - ...inventoryViewSavedObjectMappings, -}; diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 35e3be98e3982..abb266da9f066 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -61,6 +61,11 @@ export const SETTINGS_API_ROUTES = { UPDATE_PATTERN: `${API_ROOT}/settings`, }; +// App API routes +export const APP_API_ROUTES = { + CHECK_PERMISSIONS_PATTERN: `${API_ROOT}/check-permissions`, +}; + // Agent API routes export const AGENT_API_ROUTES = { LIST_PATTERN: `${FLEET_API_ROOT}/agents`, diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index 1a1bd7c65aa25..20d040ac6eaee 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -15,6 +15,7 @@ import { SETUP_API_ROUTE, OUTPUT_API_ROUTES, SETTINGS_API_ROUTES, + APP_API_ROUTES, } from '../constants'; export const epmRouteService = { @@ -126,6 +127,10 @@ export const settingsRoutesService = { getUpdatePath: () => SETTINGS_API_ROUTES.UPDATE_PATTERN, }; +export const appRoutesService = { + getCheckPermissionsPath: () => APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN, +}; + export const enrollmentAPIKeyRouteService = { getListPath: () => ENROLLMENT_API_KEY_ROUTES.LIST_PATTERN, getCreatePath: () => ENROLLMENT_API_KEY_ROUTES.CREATE_PATTERN, diff --git a/x-pack/plugins/ingest_manager/common/types/models/data_stream.ts b/x-pack/plugins/ingest_manager/common/types/models/data_stream.ts index 7da9bbad1b170..abc9ffcf6be6a 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/data_stream.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/data_stream.ts @@ -10,6 +10,11 @@ export interface DataStream { namespace: string; type: string; package: string; + package_version: string; last_activity: string; size_in_bytes: number; + dashboards: Array<{ + id: string; + title: string; + }>; } diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/app.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/app.ts new file mode 100644 index 0000000000000..b3a1a46fc54ef --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/app.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface CheckPermissionsResponse { + error?: 'MISSING_SECURITY' | 'MISSING_SUPERUSER_ROLE'; + success: boolean; +} diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts index 763fb7d820b2a..eb212050ef53e 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts @@ -14,3 +14,4 @@ export * from './enrollment_api_key'; export * from './install_script'; export * from './output'; export * from './settings'; +export * from './app'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx index 34233a00e630a..bc0a250b9a809 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ShellEnrollmentInstructions } from './shell'; export { ManualInstructions } from './manual'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx index b1da4583b74cc..5d2938f3e9fa0 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx @@ -4,33 +4,53 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText, EuiSpacer } from '@elastic/eui'; import React from 'react'; +import { EuiText, EuiSpacer, EuiCode, EuiCodeBlock, EuiCopy, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EnrollmentAPIKey } from '../../../types'; -export const ManualInstructions: React.FunctionComponent = () => { +interface Props { + kibanaUrl: string; + apiKey: EnrollmentAPIKey; + kibanaCASha256?: string; +} + +export const ManualInstructions: React.FunctionComponent = ({ + kibanaUrl, + apiKey, + kibanaCASha256, +}) => { + const command = ` +./elastic-agent enroll ${kibanaUrl} ${apiKey.api_key}${ + kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : '' + } +./elastic-agent run`; return ( <> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam vestibulum ullamcorper - turpis vitae interdum. Maecenas orci magna, auctor volutpat pellentesque eu, consectetur id - est. Nunc orci lacus, condimentum vel congue ac, fringilla eget tortor. Aliquam blandit, - nisi et congue euismod, leo lectus blandit risus, eu blandit erat metus sit amet leo. Nam - dictum lobortis condimentum. + agent enroll, + }} + /> - - Vivamus sem sapien, dictum eu tellus vel, rutrum aliquam purus. Cras quis cursus nibh. - Aliquam fermentum ipsum nec turpis luctus lobortis. Nulla facilisi. Etiam nec fringilla - urna, sed vehicula ipsum. Quisque vel pellentesque lorem, at egestas enim. Nunc semper elit - lectus, in sollicitudin erat fermentum in. Pellentesque tempus massa eget purus pharetra - blandit. - + +
{command}
+
- - Mauris congue enim nulla, nec semper est posuere non. Donec et eros eu nisi gravida - malesuada eget in velit. Morbi placerat semper euismod. Suspendisse potenti. Morbi quis - porta erat, quis cursus nulla. Aenean mauris lorem, mollis in mattis et, lobortis a lectus. - + + {copy => ( + + + + )} + ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx deleted file mode 100644 index cb65e31fb74b5..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx +++ /dev/null @@ -1,92 +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, { useState } from 'react'; -import { - EuiButtonEmpty, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiCopy, - EuiFieldText, - EuiPopover, -} from '@elastic/eui'; -import { EnrollmentAPIKey } from '../../../types'; - -// No need for i18n as these are platform names -const PLATFORMS = { - macos: 'macOS', - windows: 'Windows', - linux: 'Linux', -}; - -interface Props { - kibanaUrl: string; - kibanaCASha256?: string; - apiKey: EnrollmentAPIKey; -} - -export const ShellEnrollmentInstructions: React.FunctionComponent = ({ - kibanaUrl, - kibanaCASha256, - apiKey, -}) => { - // Platform state - const [currentPlatform, setCurrentPlatform] = useState('macos'); - const [isPlatformOptionsOpen, setIsPlatformOptionsOpen] = useState(false); - - // Build quick installation command - // const quickInstallInstructions = `${ - // kibanaCASha256 ? `CA_SHA256=${kibanaCASha256} ` : '' - // }API_KEY=${ - // apiKey.api_key - // } sh -c "$(curl ${kibanaUrl}/api/ingest_manager/fleet/install/${currentPlatform})"`; - - const quickInstallInstructions = `./elastic-agent enroll ${kibanaUrl} ${apiKey.api_key}`; - - return ( - <> - setIsPlatformOptionsOpen(true)} - > - {PLATFORMS[currentPlatform]} - - } - isOpen={isPlatformOptionsOpen} - closePopover={() => setIsPlatformOptionsOpen(false)} - > - ( - { - setCurrentPlatform(platform as typeof currentPlatform); - setIsPlatformOptionsOpen(false); - }} - > - {name} - - ))} - /> - - } - append={ - - {copy => } - - } - /> - - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/table_row_actions_nested.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/table_row_actions_nested.tsx new file mode 100644 index 0000000000000..56f010e2fa774 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/table_row_actions_nested.tsx @@ -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. + */ + +import React, { useCallback, useState } from 'react'; +import { EuiButtonIcon, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiContextMenuProps } from '@elastic/eui/src/components/context_menu/context_menu'; + +export const TableRowActionsNested = React.memo<{ panels: EuiContextMenuProps['panels'] }>( + ({ panels }) => { + const [isOpen, setIsOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + return ( + + } + isOpen={isOpen} + closePopover={handleCloseMenu} + > + + + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_link.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_link.ts new file mode 100644 index 0000000000000..f6c5b8bc03fce --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_link.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCore } from './'; + +const BASE_PATH = '/app/kibana'; + +export function useKibanaLink(path: string = '/') { + const core = useCore(); + return core.http.basePath.prepend(`${BASE_PATH}#${path}`); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/app.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/app.ts new file mode 100644 index 0000000000000..713535acbabca --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/app.ts @@ -0,0 +1,16 @@ +/* + * 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 { sendRequest } from './use_request'; +import { appRoutesService } from '../../services'; +import { CheckPermissionsResponse } from '../../types'; + +export const sendGetPermissionsCheck = () => { + return sendRequest({ + path: appRoutesService.getCheckPermissionsPath(), + method: 'get', + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts index 25cdffc5c6651..8aec20d15c888 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts @@ -13,3 +13,4 @@ export * from './epm'; export * from './outputs'; export * from './settings'; export * from './setup'; +export * from './app'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index f0a0c90a18c24..3612497e723cd 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -7,8 +7,10 @@ import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; import { useObservable } from 'react-use'; import { HashRouter as Router, Redirect, Switch, Route, RouteProps } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiErrorBoundary } from '@elastic/eui'; +import styled from 'styled-components'; +import { EuiErrorBoundary, EuiPanel, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { CoreStart, AppMountParameters } from 'src/core/public'; import { EuiThemeProvider } from '../../../../../legacy/common/eui_styled_components'; import { @@ -22,7 +24,7 @@ import { Loading, Error } from './components'; import { IngestManagerOverview, EPMApp, AgentConfigApp, FleetApp, DataStreamApp } from './sections'; import { CoreContext, DepsContext, ConfigContext, setHttpClient, useConfig } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; -import { sendSetup } from './hooks/use_request/setup'; +import { useCore, sendSetup, sendGetPermissionsCheck } from './hooks'; import { FleetStatusProvider } from './hooks/use_fleet_status'; import './index.scss'; @@ -39,86 +41,175 @@ export const ProtectedRoute: React.FunctionComponent = ({ return isAllowed ? : ; }; +const Panel = styled(EuiPanel)` + max-width: 500px; + margin-right: auto; + margin-left: auto; +`; + +const ErrorLayout = ({ children }: { children: JSX.Element }) => ( + + + {children} + + +); + const IngestManagerRoutes = ({ ...rest }) => { const { epm, fleet } = useConfig(); + const { notifications } = useCore(); + const [isPermissionsLoading, setIsPermissionsLoading] = useState(false); + const [permissionsError, setPermissionsError] = useState(); const [isInitialized, setIsInitialized] = useState(false); const [initializationError, setInitializationError] = useState(null); useEffect(() => { (async () => { + setIsPermissionsLoading(false); + setPermissionsError(undefined); setIsInitialized(false); setInitializationError(null); try { - const res = await sendSetup(); - if (res.error) { - setInitializationError(res.error); + setIsPermissionsLoading(true); + const permissionsResponse = await sendGetPermissionsCheck(); + setIsPermissionsLoading(false); + if (permissionsResponse.data?.success) { + try { + const setupResponse = await sendSetup(); + if (setupResponse.error) { + setInitializationError(setupResponse.error); + } + } catch (err) { + setInitializationError(err); + } + setIsInitialized(true); + } else { + setPermissionsError(permissionsResponse.data?.error || 'REQUEST_ERROR'); } } catch (err) { - setInitializationError(err); + setPermissionsError('REQUEST_ERROR'); } - setIsInitialized(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + if (isPermissionsLoading || permissionsError) { + return ( + + {isPermissionsLoading ? ( + + ) : permissionsError === 'REQUEST_ERROR' ? ( + + } + error={i18n.translate('xpack.ingestManager.permissionsRequestErrorMessageDescription', { + defaultMessage: 'There was a problem checking Ingest Manager permissions', + })} + /> + ) : ( + + + {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( + + ) : ( + + )} +

+ } + body={ +

+ {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( + superuser }} + /> + ) : ( + + )} +

+ } + /> + + )} + + ); + } + if (!isInitialized || initializationError) { return ( - - - - {initializationError ? ( - - } - error={initializationError} + + {initializationError ? ( + - ) : ( - - )} - - - + } + error={initializationError} + /> + ) : ( + + )} + ); } return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; @@ -142,11 +233,7 @@ const IngestManagerApp = ({ - - - - - + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx index 4a9cfe02b74ac..e9d7fcb1cf5c5 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx @@ -13,6 +13,7 @@ import { useLink, useConfig } from '../hooks'; import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH, DATA_STREAM_PATH } from '../constants'; interface Props { + showSettings?: boolean; section?: Section; children?: React.ReactNode; } @@ -33,7 +34,11 @@ const Nav = styled.nav` } `; -export const DefaultLayout: React.FunctionComponent = ({ section, children }) => { +export const DefaultLayout: React.FunctionComponent = ({ + showSettings = true, + section, + children, +}) => { const { epm, fleet } = useConfig(); const [isSettingsFlyoutOpen, setIsSettingsFlyoutOpen] = React.useState(false); @@ -109,14 +114,16 @@ export const DefaultLayout: React.FunctionComponent = ({ section, childre /> - - setIsSettingsFlyoutOpen(true)}> - - - + {showSettings ? ( + + setIsSettingsFlyoutOpen(true)}> + + + + ) : null} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx index 9f2088521ed38..39fa8c6ee8701 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx @@ -6,23 +6,9 @@ import React, { memo } from 'react'; import { dump } from 'js-yaml'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiTitle, - EuiSpacer, - EuiText, - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { AgentConfig } from '../../../../../types'; -import { - useGetOneAgentConfigFull, - useGetEnrollmentAPIKeys, - useGetOneEnrollmentAPIKey, - useCore, -} from '../../../../../hooks'; -import { ShellEnrollmentInstructions } from '../../../../../components/enrollment_instructions'; +import { useGetOneAgentConfigFull } from '../../../../../hooks'; import { Loading } from '../../../../../components'; const CONFIG_KEYS_ORDER = [ @@ -38,14 +24,7 @@ const CONFIG_KEYS_ORDER = [ ]; export const ConfigYamlView = memo<{ config: AgentConfig }>(({ config }) => { - const core = useCore(); - const fullConfigRequest = useGetOneAgentConfigFull(config.id); - const apiKeysRequest = useGetEnrollmentAPIKeys({ - page: 1, - perPage: 1000, - }); - const apiKeyRequest = useGetOneEnrollmentAPIKey(apiKeysRequest.data?.list?.[0]?.id); if (fullConfigRequest.isLoading && !fullConfigRequest.data) { return ; @@ -72,30 +51,6 @@ export const ConfigYamlView = memo<{ config: AgentConfig }>(({ config }) => { })} - {apiKeyRequest.data && ( - - -

- -

-
- - - - - - -
- )} ); }); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/components/data_stream_row_actions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/components/data_stream_row_actions.tsx new file mode 100644 index 0000000000000..ac47387cd7ab3 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/components/data_stream_row_actions.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibanaLink } from '../../../../hooks/use_kibana_link'; +import { DataStream } from '../../../../types'; +import { TableRowActionsNested } from '../../../../components/table_row_actions_nested'; + +export const DataStreamRowActions = memo<{ datastream: DataStream }>(({ datastream }) => { + const { dashboards } = datastream; + const panels = []; + const actionNameSingular = ( + + ); + const actionNamePlural = ( + + ); + + const panelTitle = i18n.translate('xpack.ingestManager.dataStreamList.viewDashboardsPanelTitle', { + defaultMessage: 'View dashboards', + }); + + if (!dashboards || dashboards.length === 0) { + panels.push({ + id: 0, + items: [ + { + icon: 'dashboardApp', + disabled: true, + name: actionNameSingular, + }, + ], + }); + } else if (dashboards.length === 1) { + panels.push({ + id: 0, + items: [ + { + icon: 'dashboardApp', + href: useKibanaLink(`/dashboard/${dashboards[0].id || ''}`), + name: actionNameSingular, + }, + ], + }); + } else { + panels.push({ + id: 0, + items: [ + { + icon: 'dashboardApp', + panel: 1, + name: actionNamePlural, + }, + ], + }); + panels.push({ + id: 1, + title: panelTitle, + items: dashboards.map(dashboard => { + return { + icon: 'dashboardApp', + href: useKibanaLink(`/dashboard/${dashboard.id || ''}`), + name: dashboard.title, + }; + }), + }); + } + + return ; +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx index d7a3e933f3bb5..cff138c6a16ca 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx @@ -20,6 +20,8 @@ import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; import { DataStream } from '../../../types'; import { WithHeaderLayout } from '../../../layouts'; import { useGetDataStreams, useStartDeps, usePagination } from '../../../hooks'; +import { PackageIcon } from '../../../components/package_icon'; +import { DataStreamRowActions } from './components/data_stream_row_actions'; const DataStreamListPageLayout: React.FunctionComponent = ({ children }) => ( = () => { const { pagination, pageSizeOptions } = usePagination(); - // Fetch agent configs + // Fetch data streams const { isLoading, data: dataStreamsData, sendRequest } = useGetDataStreams(); // Some configs retrieved, set up table props @@ -102,6 +104,23 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.ingestManager.dataStreamList.integrationColumnTitle', { defaultMessage: 'Integration', }), + render(pkg: DataStream['package'], datastream: DataStream) { + return ( + + {datastream.package_version && ( + + + + )} + {pkg} + + ); + }, }, { field: 'last_activity', @@ -135,6 +154,16 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { } }, }, + { + name: i18n.translate('xpack.ingestManager.dataStreamList.actionsColumnTitle', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: (datastream: DataStream) => , + }, + ], + }, ]; return cols; }, [fieldFormats]); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx index 12791b69d886c..6a1e6dc226903 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx @@ -30,7 +30,11 @@ export const AgentDetailsContent: React.FunctionComponent<{ title: i18n.translate('xpack.ingestManager.agentDetails.hostNameLabel', { defaultMessage: 'Host name', }), - description: agent.local_metadata['host.hostname'], + description: + typeof agent.local_metadata.host === 'object' && + typeof agent.local_metadata.host.hostname === 'string' + ? agent.local_metadata.host.hostname + : '-', }, { title: i18n.translate('xpack.ingestManager.agentDetails.hostIdLabel', { @@ -60,13 +64,22 @@ export const AgentDetailsContent: React.FunctionComponent<{ title: i18n.translate('xpack.ingestManager.agentDetails.versionLabel', { defaultMessage: 'Agent version', }), - description: agent.local_metadata['agent.version'], + description: + typeof agent.local_metadata.elastic === 'object' && + typeof agent.local_metadata.elastic.agent === 'object' && + typeof agent.local_metadata.elastic.agent.version === 'string' + ? agent.local_metadata.elastic.agent.version + : '-', }, { title: i18n.translate('xpack.ingestManager.agentDetails.platformLabel', { defaultMessage: 'Platform', }), - description: agent.local_metadata['os.platform'], + description: + typeof agent.local_metadata.os === 'object' && + typeof agent.local_metadata.os.platform === 'string' + ? agent.local_metadata.os.platform + : '-', }, ].map(({ title, description }) => { return ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx index e5d69dced7523..aa46f7cf976cd 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx @@ -75,7 +75,10 @@ export const AgentDetailsPage: React.FunctionComponent = () => {

- {agentData?.item?.local_metadata['host.hostname'] || ( + {typeof agentData?.item?.local_metadata?.host === 'object' && + typeof agentData?.item?.local_metadata?.host?.hostname === 'string' ? ( + agentData.item.local_metadata.host.hostname + ) : ( void; - agentConfigs: AgentConfig[]; -} - -export const AgentEnrollmentFlyout: React.FunctionComponent = ({ - onClose, - agentConfigs = [], -}) => { - const fleetStatus = useFleetStatus(); - const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); - - const fleetLink = useLink(FLEET_PATH); - - return ( - - - -

- -

-
-
- - {fleetStatus.isReady ? ( - <> - setSelectedAPIKeyId(keyId)} - /> - - - - ) : ( - <> - - - - ), - }} - /> - - )} - - - - - - - - - {fleetStatus.isReady && ( - - - - - - )} - - -
- ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx deleted file mode 100644 index 1d2f3bd155622..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx +++ /dev/null @@ -1,128 +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, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiText, EuiButtonGroup, EuiSteps } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - ShellEnrollmentInstructions, - ManualInstructions, -} from '../../../../../components/enrollment_instructions'; -import { useCore, useGetAgents, useGetOneEnrollmentAPIKey } from '../../../../../hooks'; -import { Loading } from '../../../components'; - -interface Props { - selectedAPIKeyId: string | undefined; -} -function useNewEnrolledAgents() { - // New enrolled agents - const [timestamp] = useState(new Date().toISOString()); - const agentsRequest = useGetAgents( - { - perPage: 100, - page: 1, - showInactive: false, - }, - { - pollIntervalMs: 3000, - } - ); - return React.useMemo(() => { - if (!agentsRequest.data) { - return []; - } - - return agentsRequest.data.list.filter(agent => agent.enrolled_at >= timestamp); - }, [agentsRequest.data, timestamp]); -} - -export const EnrollmentInstructions: React.FunctionComponent = ({ selectedAPIKeyId }) => { - const core = useCore(); - const [installType, setInstallType] = useState<'quickInstall' | 'manual'>('quickInstall'); - - const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); - - const newAgents = useNewEnrolledAgents(); - if (!apiKey.data) { - return null; - } - - return ( - <> - { - setInstallType(installType === 'manual' ? 'quickInstall' : 'manual'); - }} - buttonSize="m" - isFullWidth - /> - - {installType === 'manual' ? ( - - ) : ( - - ), - }, - { - title: i18n.translate('xpack.ingestManager.agentEnrollment.stepTestAgents', { - defaultMessage: 'Test Agents', - }), - children: ( - - {!newAgents.length ? ( - <> - - - - ) : ( - <> - - - )} - - ), - }, - ]} - /> - )} - - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx deleted file mode 100644 index 67930e51418b0..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx +++ /dev/null @@ -1,203 +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, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSelect, - EuiSpacer, - EuiText, - EuiLink, - EuiFieldText, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { AgentConfig } from '../../../../../types'; -import { useInput, useCore, sendRequest, useGetEnrollmentAPIKeys } from '../../../../../hooks'; -import { enrollmentAPIKeyRouteService } from '../../../../../services'; - -interface Props { - onKeyChange: (keyId: string | undefined) => void; - agentConfigs: AgentConfig[]; -} - -function useCreateApiKeyForm(configId: string | undefined, onSuccess: (keyId: string) => void) { - const { notifications } = useCore(); - const [isLoading, setIsLoading] = useState(false); - const apiKeyNameInput = useInput(''); - - const onSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setIsLoading(true); - try { - const res = await sendRequest({ - method: 'post', - path: enrollmentAPIKeyRouteService.getCreatePath(), - body: JSON.stringify({ - name: apiKeyNameInput.value, - config_id: configId, - }), - }); - apiKeyNameInput.clear(); - setIsLoading(false); - onSuccess(res.data.item.id); - } catch (err) { - notifications.toasts.addError(err as Error, { - title: 'Error', - }); - setIsLoading(false); - } - }; - - return { - isLoading, - onSubmit, - apiKeyNameInput, - }; -} - -export const APIKeySelection: React.FunctionComponent = ({ onKeyChange, agentConfigs }) => { - const enrollmentAPIKeysRequest = useGetEnrollmentAPIKeys({ - page: 1, - perPage: 1000, - }); - - const [selectedState, setSelectedState] = useState<{ - agentConfigId?: string; - enrollmentAPIKeyId?: string; - }>({ - agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined, - }); - const filteredEnrollmentAPIKeys = React.useMemo(() => { - if (!selectedState.agentConfigId || !enrollmentAPIKeysRequest.data) { - return []; - } - - return enrollmentAPIKeysRequest.data.list.filter( - key => key.config_id === selectedState.agentConfigId - ); - }, [enrollmentAPIKeysRequest.data, selectedState.agentConfigId]); - - // Select first API key when config change - React.useEffect(() => { - if (!selectedState.enrollmentAPIKeyId && filteredEnrollmentAPIKeys.length > 0) { - const enrollmentAPIKeyId = filteredEnrollmentAPIKeys[0].id; - setSelectedState({ - agentConfigId: selectedState.agentConfigId, - enrollmentAPIKeyId, - }); - onKeyChange(enrollmentAPIKeyId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredEnrollmentAPIKeys, selectedState.enrollmentAPIKeyId, selectedState.agentConfigId]); - - const [showAPIKeyForm, setShowAPIKeyForm] = useState(false); - const apiKeyForm = useCreateApiKeyForm(selectedState.agentConfigId, async (keyId: string) => { - const res = await enrollmentAPIKeysRequest.sendRequest(); - setSelectedState({ - ...selectedState, - enrollmentAPIKeyId: res.data?.list.find(key => key.id === keyId)?.id, - }); - setShowAPIKeyForm(false); - }); - - return ( - <> - - - - - - - - } - > - ({ - value: agentConfig.id, - text: agentConfig.name, - }))} - value={selectedState.agentConfigId || undefined} - onChange={e => - setSelectedState({ - agentConfigId: e.target.value, - enrollmentAPIKeyId: undefined, - }) - } - /> - - - - - } - labelAppend={ - - setShowAPIKeyForm(!showAPIKeyForm)} color="primary"> - {showAPIKeyForm ? ( - - ) : ( - - )} - - - } - > - {showAPIKeyForm ? ( -
- - - ) : ( - ({ - value: key.id, - text: key.name, - }))} - value={selectedState.enrollmentAPIKeyId || undefined} - onChange={e => { - setSelectedState({ - ...selectedState, - enrollmentAPIKeyId: e.target.value, - }); - onKeyChange(selectedState.enrollmentAPIKeyId); - }} - /> - )} -
-
-
- - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 5e7fe745a0c4a..84056df2aca32 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -25,7 +25,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; import { CSSProperties } from 'styled-components'; -import { AgentEnrollmentFlyout } from './components'; +import { AgentEnrollmentFlyout } from '../components'; import { Agent } from '../../../types'; import { usePagination, @@ -146,6 +146,13 @@ const RowActions = React.memo<{ agent: Agent; onReassignClick: () => void; refre } ); +function safeMetadata(val: any) { + if (typeof val !== 'string') { + return '-'; + } + return val; +} + export const AgentListPage: React.FunctionComponent<{}> = () => { const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || ''; const hasWriteCapabilites = useCapabilities().write; @@ -238,13 +245,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const columns = [ { - field: 'local_metadata.host', + field: 'local_metadata.host.hostname', name: i18n.translate('xpack.ingestManager.agentList.hostColumnTitle', { defaultMessage: 'Host', }), render: (host: string, agent: Agent) => ( - {agent.local_metadata['host.hostname'] || host || ''} + {safeMetadata(host)} ), }, @@ -308,13 +315,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, }, { - field: 'local_metadata.version', + field: 'local_metadata.elastic.agent.version', width: '100px', name: i18n.translate('xpack.ingestManager.agentList.versionTitle', { defaultMessage: 'Version', }), - render: (version: string, agent: Agent) => - agent.local_metadata['agent.version'] || version || '', + render: (version: string, agent: Agent) => safeMetadata(version), }, { field: 'last_checkin', @@ -505,7 +511,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { loading={isLoading && agentsRequest.isInitialRequest} hasActions={true} noItemsMessage={ - isLoading ? ( + isLoading && agentsRequest.isInitialRequest ? ( = ({ agentConfigId }) => { + const agentConfigRequest = useGetOneAgentConfig(agentConfigId); + const agentConfig = agentConfigRequest.data ? agentConfigRequest.data.item : null; + + if (!agentConfig) { + return null; + } + return ( + <> + + {agentConfig.datasources.length}, + }} + /> + + + {(agentConfig.datasources as Datasource[]).map((datasource, idx) => { + if (!datasource.package) { + return null; + } + return ( + + + + + + {datasource.package.title} + + + ); + })} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx new file mode 100644 index 0000000000000..a8cebfdf899a6 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSelect, EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; +import { AgentConfig } from '../../../../types'; +import { useGetEnrollmentAPIKeys } from '../../../../hooks'; +import { AgentConfigDatasourceBadges } from '../agent_config_datasource_badges'; + +interface Props { + agentConfigs: AgentConfig[]; + onKeyChange: (key: string) => void; +} + +export const EnrollmentStepAgentConfig: React.FC = ({ agentConfigs, onKeyChange }) => { + const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState(false); + const enrollmentAPIKeysRequest = useGetEnrollmentAPIKeys({ + page: 1, + perPage: 1000, + }); + + const [selectedState, setSelectedState] = useState<{ + agentConfigId?: string; + enrollmentAPIKeyId?: string; + }>({ + agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined, + }); + const filteredEnrollmentAPIKeys = React.useMemo(() => { + if (!selectedState.agentConfigId || !enrollmentAPIKeysRequest.data) { + return []; + } + + return enrollmentAPIKeysRequest.data.list.filter( + key => key.config_id === selectedState.agentConfigId + ); + }, [enrollmentAPIKeysRequest.data, selectedState.agentConfigId]); + + // Select first API key when config change + React.useEffect(() => { + if (!selectedState.enrollmentAPIKeyId && filteredEnrollmentAPIKeys.length > 0) { + const enrollmentAPIKeyId = filteredEnrollmentAPIKeys[0].id; + setSelectedState({ + agentConfigId: selectedState.agentConfigId, + enrollmentAPIKeyId, + }); + onKeyChange(enrollmentAPIKeyId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredEnrollmentAPIKeys, selectedState.enrollmentAPIKeyId, selectedState.agentConfigId]); + + return ( + <> + + + + } + options={agentConfigs.map(config => ({ + value: config.id, + text: config.name, + }))} + value={selectedState.agentConfigId || undefined} + onChange={e => + setSelectedState({ + agentConfigId: e.target.value, + enrollmentAPIKeyId: undefined, + }) + } + aria-label={i18n.translate( + 'xpack.ingestManager.enrollmentStepAgentConfig.configSelectAriaLabel', + { defaultMessage: 'Agent configuration' } + )} + /> + + {selectedState.agentConfigId && ( + + )} + + setIsAuthenticationSettingsOpen(!isAuthenticationSettingsOpen)} + > + + + {isAuthenticationSettingsOpen && ( + <> + + ({ + value: key.id, + text: key.name, + }))} + value={selectedState.enrollmentAPIKeyId || undefined} + prepend={ + + + + } + onChange={e => { + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: e.target.value, + }); + onKeyChange(e.target.value); + }} + /> + + )} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx new file mode 100644 index 0000000000000..ff7c2f705e7b7 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx @@ -0,0 +1,157 @@ +/* + * 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 { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiFlyoutFooter, + EuiSteps, + EuiText, + EuiLink, +} from '@elastic/eui'; +import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentConfig } from '../../../../types'; +import { EnrollmentStepAgentConfig } from './config_selection'; +import { useGetOneEnrollmentAPIKey, useCore, useGetSettings, useLink } from '../../../../hooks'; +import { ManualInstructions } from '../../../../components/enrollment_instructions'; +import { FLEET_PATH } from '../../../../constants'; +import { useFleetStatus } from '../../../../hooks/use_fleet_status'; + +interface Props { + onClose: () => void; + agentConfigs: AgentConfig[]; +} + +export const AgentEnrollmentFlyout: React.FunctionComponent = ({ + onClose, + agentConfigs = [], +}) => { + const core = useCore(); + const fleetStatus = useFleetStatus(); + const fleetLink = useLink(FLEET_PATH); + + const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + + const settings = useGetSettings(); + const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); + + const kibanaUrl = + settings.data?.item?.kibana_url ?? `${window.location.origin}${core.http.basePath.get()}`; + const kibanaCASha256 = settings.data?.item?.kibana_ca_sha256; + + const steps: EuiContainedStepProps[] = [ + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepDownloadAgentTitle', { + defaultMessage: 'Download the Elastic Agent', + }), + children: ( + + + + + ), + }} + /> + + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepChooseAgentConfigTitle', { + defaultMessage: 'Choose an agent configuration', + }), + children: ( + + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepRunAgentTitle', { + defaultMessage: 'Enroll and run the Elastic Agent', + }), + children: apiKey.data && ( + + ), + }, + ]; + + return ( + + + +

+ +

+
+
+ + {fleetStatus.isReady ? ( + <> + + + ) : ( + <> + + + + ), + }} + /> + + )} + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx index 692c60cdce38c..2c103ade31f5b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx @@ -19,17 +19,11 @@ import { EuiSelect, EuiFormRow, EuiText, - EuiBadge, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Datasource, Agent } from '../../../../types'; -import { - useGetOneAgentConfig, - sendPutAgentReassign, - useCore, - useGetAgentConfigs, -} from '../../../../hooks'; -import { PackageIcon } from '../../../../components/package_icon'; +import { Agent } from '../../../../types'; +import { sendPutAgentReassign, useCore, useGetAgentConfigs } from '../../../../hooks'; +import { AgentConfigDatasourceBadges } from '../agent_config_datasource_badges'; interface Props { onClose: () => void; @@ -45,9 +39,6 @@ export const AgentReassignConfigFlyout: React.FunctionComponent = ({ onCl const agentConfigsRequest = useGetAgentConfigs(); const agentConfigs = agentConfigsRequest.data ? agentConfigsRequest.data.items : []; - const agentConfigRequest = useGetOneAgentConfig(selectedAgentConfigId); - const agentConfig = agentConfigRequest.data ? agentConfigRequest.data.item : null; - const [isSubmitting, setIsSubmitting] = useState(false); async function onSubmit() { @@ -121,40 +112,9 @@ export const AgentReassignConfigFlyout: React.FunctionComponent = ({ onCl - {agentConfig && ( - - {agentConfig.datasources.length}, - }} - /> - + {selectedAgentConfigId && ( + )} - - {agentConfig && - (agentConfig.datasources as Datasource[]).map((datasource, idx) => { - if (!datasource.package) { - return null; - } - return ( - - - - - - {datasource.package.title} - - - ); - })} @@ -168,7 +128,7 @@ export const AgentReassignConfigFlyout: React.FunctionComponent = ({ onCl { try { - const { isInitialized: success } = await core.http.post(setupRouteService.getSetupPath()); - return { success }; + const permissionsResponse = await core.http.get(appRoutesService.getCheckPermissionsPath()); + if (permissionsResponse.success) { + const { isInitialized: success } = await core.http.post(setupRouteService.getSetupPath()); + return { success }; + } else { + throw new Error(permissionsResponse.error); + } } catch (error) { return { success: false, error: { message: error.body?.message || 'Unknown error' } }; } diff --git a/x-pack/plugins/ingest_manager/server/constants/index.ts b/x-pack/plugins/ingest_manager/server/constants/index.ts index 75c14ffc8fa84..3468c56cc877f 100644 --- a/x-pack/plugins/ingest_manager/server/constants/index.ts +++ b/x-pack/plugins/ingest_manager/server/constants/index.ts @@ -22,6 +22,7 @@ export { OUTPUT_API_ROUTES, SETUP_API_ROUTE, SETTINGS_API_ROUTES, + APP_API_ROUTES, // Saved object types AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE, diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 3b0837565c36c..24f3a11789dc7 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -11,7 +11,7 @@ import { Plugin, PluginInitializerContext, SavedObjectsServiceStart, - HttpServerInfo, + HttpServiceSetup, } from 'kibana/server'; import { LicensingPluginSetup, ILicense } from '../../licensing/server'; import { @@ -42,6 +42,7 @@ import { registerInstallScriptRoutes, registerOutputRoutes, registerSettingsRoutes, + registerAppRoutes, } from './routes'; import { IngestManagerConfigType } from '../common'; import { @@ -70,8 +71,9 @@ export interface IngestManagerAppContext { config$?: Observable; savedObjects: SavedObjectsServiceStart; isProductionMode: boolean; - serverInfo?: HttpServerInfo; + kibanaVersion: string; cloud?: CloudSetup; + httpSetup?: HttpServiceSetup; } export type IngestManagerSetupContract = void; @@ -108,15 +110,17 @@ export class IngestManagerPlugin private cloud: CloudSetup | undefined; private isProductionMode: boolean; - private serverInfo: HttpServerInfo | undefined; + private kibanaVersion: string; + private httpSetup: HttpServiceSetup | undefined; constructor(private readonly initializerContext: PluginInitializerContext) { this.config$ = this.initializerContext.config.create(); this.isProductionMode = this.initializerContext.env.mode.prod; + this.kibanaVersion = this.initializerContext.env.packageInfo.version; } public async setup(core: CoreSetup, deps: IngestManagerSetupDeps) { - this.serverInfo = core.http.getServerInfo(); + this.httpSetup = core.http; this.licensing$ = deps.licensing.license$; if (deps.security) { this.security = deps.security; @@ -161,27 +165,31 @@ export class IngestManagerPlugin const router = core.http.createRouter(); const config = await this.config$.pipe(first()).toPromise(); - // Register routes - registerSetupRoutes(router, config); - registerAgentConfigRoutes(router); - registerDatasourceRoutes(router); - registerOutputRoutes(router); - registerSettingsRoutes(router); - registerDataStreamRoutes(router); - - // Conditional routes - if (config.epm.enabled) { - registerEPMRoutes(router); - } - - if (config.fleet.enabled) { - registerAgentRoutes(router); - registerEnrollmentApiKeyRoutes(router); - registerInstallScriptRoutes({ - router, - serverInfo: core.http.getServerInfo(), - basePath: core.http.basePath, - }); + // Always register app routes for permissions checking + registerAppRoutes(router); + + // Register rest of routes only if security is enabled + if (this.security) { + registerSetupRoutes(router, config); + registerAgentConfigRoutes(router); + registerDatasourceRoutes(router); + registerOutputRoutes(router); + registerSettingsRoutes(router); + registerDataStreamRoutes(router); + + // Conditional config routes + if (config.epm.enabled) { + registerEPMRoutes(router); + } + + if (config.fleet.enabled) { + registerAgentRoutes(router); + registerEnrollmentApiKeyRoutes(router); + registerInstallScriptRoutes({ + router, + basePath: core.http.basePath, + }); + } } } @@ -197,7 +205,8 @@ export class IngestManagerPlugin config$: this.config$, savedObjects: core.savedObjects, isProductionMode: this.isProductionMode, - serverInfo: this.serverInfo, + kibanaVersion: this.kibanaVersion, + httpSetup: this.httpSetup, cloud: this.cloud, }); licenseService.start(this.licensing$); diff --git a/x-pack/plugins/ingest_manager/server/routes/app/index.ts b/x-pack/plugins/ingest_manager/server/routes/app/index.ts new file mode 100644 index 0000000000000..9d666efc7e9ce --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/app/index.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter, RequestHandler } from 'src/core/server'; +import { PLUGIN_ID, APP_API_ROUTES } from '../../constants'; +import { appContextService } from '../../services'; +import { CheckPermissionsResponse } from '../../../common'; + +export const getCheckPermissionsHandler: RequestHandler = async (context, request, response) => { + const body: CheckPermissionsResponse = { success: true }; + try { + const security = await appContextService.getSecurity(); + const user = security.authc.getCurrentUser(request); + + if (!user?.roles.includes('superuser')) { + body.success = false; + body.error = 'MISSING_SUPERUSER_ROLE'; + return response.ok({ + body, + }); + } + + return response.ok({ body: { success: true } }); + } catch (e) { + body.success = false; + body.error = 'MISSING_SECURITY'; + return response.ok({ + body, + }); + } +}; + +export const registerRoutes = (router: IRouter) => { + router.get( + { + path: APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN, + validate: {}, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getCheckPermissionsHandler + ); +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts index 0d2909edf00c4..80a33c26d86da 100644 --- a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts @@ -3,9 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandler } from 'src/core/server'; +import { RequestHandler, SavedObjectsClientContract } from 'src/core/server'; import { DataStream } from '../../types'; -import { GetDataStreamsResponse } from '../../../common'; +import { GetDataStreamsResponse, KibanaAssetType } from '../../../common'; +import { getPackageSavedObjects, getKibanaSavedObject } from '../../services/epm/packages/get'; const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*'; @@ -69,12 +70,6 @@ export const getListHandler: RequestHandler = async (context, request, response) size: 1, }, }, - package: { - terms: { - field: 'event.module', - size: 1, - }, - }, last_activity: { max: { field: '@timestamp', @@ -100,26 +95,62 @@ export const getListHandler: RequestHandler = async (context, request, response) index: { buckets: indexResults }, } = aggregations; - const dataStreams: DataStream[] = (indexResults as any[]).map(result => { + const packageSavedObjects = await getPackageSavedObjects(context.core.savedObjects.client); + const packageMetadata: any = {}; + + const dataStreamsPromises = (indexResults as any[]).map(async result => { const { key: indexName, dataset: { buckets: datasetBuckets }, namespace: { buckets: namespaceBuckets }, type: { buckets: typeBuckets }, - package: { buckets: packageBuckets }, last_activity: { value_as_string: lastActivity }, } = result; + + // We don't have a reliable way to associate index with package ID, so + // this is a hack to extract the package ID from the first part of the dataset name + // with fallback to extraction from index name + const pkg = datasetBuckets.length + ? datasetBuckets[0].key.split('.')[0] + : indexName.split('-')[1].split('.')[0]; + const pkgSavedObject = packageSavedObjects.saved_objects.filter(p => p.id === pkg); + + // if + // - the datastream is associated with a package + // - and the package has been installed through EPM + // - and we didn't pick the metadata in an earlier iteration of this map() + if (pkg !== '' && pkgSavedObject.length > 0 && !packageMetadata[pkg]) { + // then pick the dashboards from the package saved object + const dashboards = + pkgSavedObject[0].attributes?.installed?.filter( + o => o.type === KibanaAssetType.dashboard + ) || []; + // and then pick the human-readable titles from the dashboard saved objects + const enhancedDashboards = await getEnhancedDashboards( + context.core.savedObjects.client, + dashboards + ); + + packageMetadata[pkg] = { + version: pkgSavedObject[0].attributes?.version || '', + dashboards: enhancedDashboards, + }; + } return { index: indexName, dataset: datasetBuckets.length ? datasetBuckets[0].key : '', namespace: namespaceBuckets.length ? namespaceBuckets[0].key : '', type: typeBuckets.length ? typeBuckets[0].key : '', - package: packageBuckets.length ? packageBuckets[0].key : '', + package: pkg, + package_version: packageMetadata[pkg] ? packageMetadata[pkg].version : '', last_activity: lastActivity, size_in_bytes: indexStats[indexName] ? indexStats[indexName].total.store.size_in_bytes : 0, + dashboards: packageMetadata[pkg] ? packageMetadata[pkg].dashboards : [], }; }); + const dataStreams: DataStream[] = await Promise.all(dataStreamsPromises); + body.data_streams = dataStreams; return response.ok({ @@ -132,3 +163,21 @@ export const getListHandler: RequestHandler = async (context, request, response) }); } }; + +const getEnhancedDashboards = async ( + savedObjectsClient: SavedObjectsClientContract, + dashboards: any[] +) => { + const dashboardsPromises = dashboards.map(async db => { + const dbSavedObject: any = await getKibanaSavedObject( + savedObjectsClient, + KibanaAssetType.dashboard, + db.id + ); + return { + id: db.id, + title: dbSavedObject.attributes?.title || db.id, + }; + }); + return await Promise.all(dashboardsPromises); +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/index.ts b/x-pack/plugins/ingest_manager/server/routes/index.ts index 3ce34d15de46c..0978c2aa57bf6 100644 --- a/x-pack/plugins/ingest_manager/server/routes/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/index.ts @@ -13,3 +13,4 @@ export { registerRoutes as registerEnrollmentApiKeyRoutes } from './enrollment_a export { registerRoutes as registerInstallScriptRoutes } from './install_script'; export { registerRoutes as registerOutputRoutes } from './output'; export { registerRoutes as registerSettingsRoutes } from './settings'; +export { registerRoutes as registerAppRoutes } from './app'; diff --git a/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts b/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts index b007e61594e9d..2a8d4fdbec497 100644 --- a/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts @@ -4,27 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ import url from 'url'; -import { IRouter, BasePath, HttpServerInfo, KibanaRequest } from 'src/core/server'; +import { IRouter, BasePath, KibanaRequest } from 'src/core/server'; import { INSTALL_SCRIPT_API_ROUTES } from '../../constants'; import { getScript } from '../../services/install_script'; import { InstallScriptRequestSchema } from '../../types'; +import { appContextService, settingsService } from '../../services'; + +function getInternalUserSOClient(request: KibanaRequest) { + // soClient as kibana internal users, be carefull on how you use it, security is not enabled + return appContextService.getSavedObjects().getScopedClient(request, { + excludedWrappers: ['security'], + }); +} export const registerRoutes = ({ router, - basePath, - serverInfo, }: { router: IRouter; basePath: Pick; - serverInfo: HttpServerInfo; }) => { - const kibanaUrl = url.format({ - protocol: serverInfo.protocol, - hostname: serverInfo.host, - port: serverInfo.port, - pathname: basePath.serverBasePath, - }); - router.get( { path: INSTALL_SCRIPT_API_ROUTES, @@ -36,6 +34,19 @@ export const registerRoutes = ({ request: KibanaRequest<{ osType: 'macos' }>, response ) { + const soClient = getInternalUserSOClient(request); + const http = appContextService.getHttpSetup(); + const serverInfo = http.getServerInfo(); + const basePath = http.basePath; + const kibanaUrl = + (await settingsService.getSettings(soClient)).kibana_url || + url.format({ + protocol: serverInfo.protocol, + hostname: serverInfo.host, + port: serverInfo.port, + pathname: basePath.serverBasePath, + }); + const script = getScript(request.params.osType, kibanaUrl); return response.ok({ body: script }); diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 542dfa9cefe8f..abe5f3620d214 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -13,7 +13,7 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re try { const isAdminUserSetup = (await outputService.getAdminUser(soClient)) !== null; const isApiKeysEnabled = await appContextService.getSecurity().authc.areAPIKeysEnabled(); - const isTLSEnabled = appContextService.getServerInfo().protocol === 'https'; + const isTLSEnabled = appContextService.getHttpSetup().getServerInfo().protocol === 'https'; const isProductionMode = appContextService.getIsProductionMode(); const isCloud = appContextService.getCloud()?.isCloudEnabled ?? false; const isTLSCheckDisabled = appContextService.getConfig()?.fleet?.tlsCheckDisabled ?? false; diff --git a/x-pack/plugins/ingest_manager/server/services/app_context.ts b/x-pack/plugins/ingest_manager/server/services/app_context.ts index 5e538ad84b4c2..6da0a137fa087 100644 --- a/x-pack/plugins/ingest_manager/server/services/app_context.ts +++ b/x-pack/plugins/ingest_manager/server/services/app_context.ts @@ -5,7 +5,7 @@ */ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { SavedObjectsServiceStart, HttpServerInfo } from 'src/core/server'; +import { SavedObjectsServiceStart, HttpServiceSetup } from 'src/core/server'; import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../../security/server'; import { IngestManagerConfigType } from '../../common'; @@ -18,17 +18,19 @@ class AppContextService { private config$?: Observable; private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; - private serverInfo: HttpServerInfo | undefined; private isProductionMode: boolean = false; + private kibanaVersion: string | undefined; private cloud?: CloudSetup; + private httpSetup?: HttpServiceSetup; public async start(appContext: IngestManagerAppContext) { this.encryptedSavedObjects = appContext.encryptedSavedObjects; this.security = appContext.security; this.savedObjects = appContext.savedObjects; - this.serverInfo = appContext.serverInfo; this.isProductionMode = appContext.isProductionMode; this.cloud = appContext.cloud; + this.kibanaVersion = appContext.kibanaVersion; + this.httpSetup = appContext.httpSetup; if (appContext.config$) { this.config$ = appContext.config$; @@ -77,11 +79,18 @@ class AppContextService { return this.isProductionMode; } - public getServerInfo() { - if (!this.serverInfo) { - throw new Error('Server info not set.'); + public getHttpSetup() { + if (!this.httpSetup) { + throw new Error('HttpServiceSetup not set.'); } - return this.serverInfo; + return this.httpSetup; + } + + public getKibanaVersion() { + if (!this.kibanaVersion) { + throw new Error('Kibana version is not set.'); + } + return this.kibanaVersion; } } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index da8d79a04b97c..6db08e344b3da 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -6,7 +6,7 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; -import { Installation, InstallationStatus, PackageInfo } from '../../../types'; +import { Installation, InstallationStatus, PackageInfo, KibanaAssetType } from '../../../types'; import * as Registry from '../registry'; import { createInstallableFrom } from './index'; @@ -32,11 +32,10 @@ export async function getPackages( ); }); // get the installed packages - const results = await savedObjectsClient.find({ - type: PACKAGES_SAVED_OBJECT_TYPE, - }); + const packageSavedObjects = await getPackageSavedObjects(savedObjectsClient); + // filter out any internal packages - const savedObjectsVisible = results.saved_objects.filter(o => !o.attributes.internal); + const savedObjectsVisible = packageSavedObjects.saved_objects.filter(o => !o.attributes.internal); const packageList = registryItems .map(item => createInstallableFrom( @@ -48,6 +47,12 @@ export async function getPackages( return packageList; } +export async function getPackageSavedObjects(savedObjectsClient: SavedObjectsClientContract) { + return savedObjectsClient.find({ + type: PACKAGES_SAVED_OBJECT_TYPE, + }); +} + export async function getPackageKeysByStatus( savedObjectsClient: SavedObjectsClientContract, status: InstallationStatus @@ -114,3 +119,11 @@ function sortByName(a: { name: string }, b: { name: string }) { return 0; } } + +export async function getKibanaSavedObject( + savedObjectsClient: SavedObjectsClientContract, + type: KibanaAssetType, + id: string +) { + return savedObjectsClient.get(type, id); +} diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/index.ts b/x-pack/plugins/ingest_manager/server/services/install_script/index.ts index 7e7f8d2a3734b..02386531f5d61 100644 --- a/x-pack/plugins/ingest_manager/server/services/install_script/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/install_script/index.ts @@ -4,14 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { appContextService } from '../app_context'; import { macosInstallTemplate } from './install_templates/macos'; +import { linuxInstallTemplate } from './install_templates/linux'; -export function getScript(osType: 'macos', kibanaUrl: string): string { - const variables = { kibanaUrl }; +export function getScript(osType: 'macos' | 'linux', kibanaUrl: string): string { + const variables = { kibanaUrl, kibanaVersion: appContextService.getKibanaVersion() }; switch (osType) { case 'macos': return macosInstallTemplate(variables); + case 'linux': + return linuxInstallTemplate(variables); default: throw new Error(`${osType} is not supported.`); } diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/linux.ts b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/linux.ts new file mode 100644 index 0000000000000..0bb68c40bc580 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/linux.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InstallTemplateFunction } from './types'; + +export const linuxInstallTemplate: InstallTemplateFunction = variables => { + const artifact = `elastic-agent-${variables.kibanaVersion}-linux-x86_64`; + + return `#!/bin/sh + +set -e +curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/${artifact}.tar.gz +tar -xzvf ${artifact}.tar.gz +cd ${artifact} +./elastic-agent enroll ${variables.kibanaUrl} $API_KEY --force +./elastic-agent run +`; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts index e59dc6174b40f..11bb58d184d33 100644 --- a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts +++ b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts @@ -4,12 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { resolve } from 'path'; import { InstallTemplateFunction } from './types'; -const PROJECT_ROOT = resolve(__dirname, '../../../../'); -export const macosInstallTemplate: InstallTemplateFunction = variables => `#!/bin/sh +export const macosInstallTemplate: InstallTemplateFunction = variables => { + const artifact = `elastic-agent-${variables.kibanaVersion}-darwin-x86_64`; -eval "node ${PROJECT_ROOT}/scripts/dev_agent --enrollmentApiKey=$API_KEY --kibanaUrl=${variables.kibanaUrl}" + return `#!/bin/sh +set -e +curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/${artifact}.tar.gz +tar -xzvf ${artifact}.tar.gz +cd ${artifact} +./elastic-agent enroll ${variables.kibanaUrl} $API_KEY --force +./elastic-agent run `; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts index a478beaa96cfc..65d57f8ac7dbf 100644 --- a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts +++ b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts @@ -4,4 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export type InstallTemplateFunction = (variables: { kibanaUrl: string }) => string; +export type InstallTemplateFunction = (variables: { + kibanaUrl: string; + kibanaVersion: string; +}) => string; diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index 3619628bd4f8b..22acce8d4a51c 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import url from 'url'; import uuid from 'uuid'; import { SavedObjectsClientContract } from 'src/core/server'; import { CallESAsCurrentUser } from '../types'; @@ -38,10 +39,21 @@ export async function setupIngestManager( agentConfigService.ensureDefaultAgentConfig(soClient), settingsService.getSettings(soClient).catch((e: any) => { if (e.isBoom && e.output.statusCode === 404) { + const http = appContextService.getHttpSetup(); + const serverInfo = http.getServerInfo(); + const basePath = http.basePath; + + const defaultKibanaUrl = url.format({ + protocol: serverInfo.protocol, + hostname: serverInfo.host, + port: serverInfo.port, + pathname: basePath.serverBasePath, + }); + return settingsService.saveSettings(soClient, { agent_auto_upgrade: true, package_auto_upgrade: true, - kibana_url: appContextService.getConfig()?.fleet?.kibana?.host, + kibana_url: appContextService.getConfig()?.fleet?.kibana?.host ?? defaultKibanaUrl, }); } diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts index cf676129cce7a..f872efc006b76 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts @@ -8,6 +8,6 @@ import { schema } from '@kbn/config-schema'; export const InstallScriptRequestSchema = { params: schema.object({ - osType: schema.oneOf([schema.literal('macos')]), + osType: schema.oneOf([schema.literal('macos'), schema.literal('linux')]), }), }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_flyout.tsx index 98243a5149c0d..0ccc097505e5d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_flyout.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_flyout.tsx @@ -73,7 +73,10 @@ export const PipelineDetailsFlyout: FunctionComponent = ({ defaultMessage: 'Delete', }), icon: , - onClick: () => onDeleteClick([pipeline.name]), + onClick: () => { + setShowPopover(false); + onDeleteClick([pipeline.name]); + }, }, ]; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx index c90ac2714a95a..0d38905c0fb49 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -125,14 +125,7 @@ export const PipelinesList: React.FunctionComponent = ({ } else { // Somehow we triggered show pipeline details, but do not have a pipeline. // We assume not found. - return ( - { - goHome(); - }} - pipelineName={pipelineNameFromLocation} - /> - ); + return ; } }; @@ -195,9 +188,10 @@ export const PipelinesList: React.FunctionComponent = ({ if (deleteResponse?.hasDeletedPipelines) { // reload pipelines list sendRequest(); + setSelectedPipeline(undefined); + goHome(); } setPipelinesToDelete([]); - setSelectedPipeline(undefined); }} pipelinesToDelete={pipelinesToDelete} /> diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 41d0e3a7aa9a0..888854a4e83b8 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -104,8 +104,8 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp: string | undefined; }> { return ({ navigation: navigationStartMock, @@ -140,7 +140,7 @@ describe('Lens App', () => { load: jest.fn(), save: jest.fn(), }, - redirectTo: jest.fn(id => {}), + redirectTo: jest.fn((id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => {}), } as unknown) as jest.Mocked<{ navigation: typeof navigationStartMock; editorFrame: EditorFrameInstance; @@ -149,8 +149,8 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp: string | undefined; }>; } @@ -336,6 +336,7 @@ describe('Lens App', () => { describe('save button', () => { interface SaveProps { newCopyOnSave: boolean; + returnToOrigin?: boolean; newTitle: string; } @@ -347,8 +348,8 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp: string | undefined; }>; beforeEach(() => { @@ -374,32 +375,25 @@ describe('Lens App', () => { async function testSave(inst: ReactWrapper, saveProps: SaveProps) { await getButton(inst).run(inst.getDOMNode()); - inst.update(); - - const handler = inst.findWhere(el => el.prop('onSave')).prop('onSave') as ( + const handler = inst.find('[data-test-subj="lnsApp_saveModalOrigin"]').prop('onSave') as ( p: unknown ) => void; handler(saveProps); } async function save({ - initialDocId, - addToDashboardMode, lastKnownDoc = { expression: 'kibana 3' }, + initialDocId, ...saveProps }: SaveProps & { lastKnownDoc?: object; initialDocId?: string; - addToDashboardMode?: boolean; }) { const args = { ...defaultArgs, docId: initialDocId, }; - if (addToDashboardMode) { - args.addToDashboardMode = addToDashboardMode; - } args.editorFrame = frame; (args.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', @@ -438,7 +432,7 @@ describe('Lens App', () => { expect(getButton(instance).disableButton).toEqual(false); await act(async () => { - testSave(instance, saveProps); + testSave(instance, { ...saveProps }); }); return { args, instance }; @@ -527,7 +521,7 @@ describe('Lens App', () => { expression: 'kibana 3', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa'); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true); inst.setProps({ docId: 'aaa' }); @@ -547,7 +541,7 @@ describe('Lens App', () => { expression: 'kibana 3', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa'); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true); inst.setProps({ docId: 'aaa' }); @@ -601,10 +595,10 @@ describe('Lens App', () => { expect(getButton(instance).disableButton).toEqual(false); }); - it('saves new doc and redirects to dashboard', async () => { + it('saves new doc and redirects to originating app', async () => { const { args } = await save({ initialDocId: undefined, - addToDashboardMode: true, + returnToOrigin: true, newCopyOnSave: false, newTitle: 'hello there', }); @@ -615,7 +609,7 @@ describe('Lens App', () => { title: 'hello there', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa'); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', true, true); }); it('saves app filters and does not save pinned filters', async () => { @@ -666,7 +660,6 @@ describe('Lens App', () => { }) ); instance.update(); - await act(async () => getButton(instance).run(instance.getDOMNode())); instance.update(); @@ -684,7 +677,7 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; }>; beforeEach(() => { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 28135dd12a724..6b8248fa2030b 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -12,9 +12,11 @@ import { Query, DataPublicPluginStart } from 'src/plugins/data/public'; import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import { AppMountContext, NotificationsStart } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { FormattedMessage } from '@kbn/i18n/react'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { SavedObjectSaveModal } from '../../../../../src/plugins/saved_objects/public'; +import { + SavedObjectSaveModalOrigin, + OnSaveProps, +} from '../../../../../src/plugins/saved_objects/public'; import { Document, SavedObjectStore } from '../persistence'; import { EditorFrameInstance } from '../types'; import { NativeRenderer } from '../native_renderer'; @@ -52,7 +54,7 @@ export function App({ docId, docStorage, redirectTo, - addToDashboardMode, + originatingApp, navigation, }: { editorFrame: EditorFrameInstance; @@ -62,8 +64,8 @@ export function App({ storage: IStorageWrapper; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp?: string | undefined; }) { const language = storage.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage'); @@ -182,6 +184,63 @@ export function App({ lastKnownDoc.expression.length > 0 && core.application.capabilities.visualize.save; + const runSave = ( + saveProps: Omit & { + returnToOrigin: boolean; + } + ) => { + if (!lastKnownDoc) { + return; + } + const [pinnedFilters, appFilters] = _.partition( + lastKnownDoc.state?.filters, + esFilters.isFilterPinned + ); + const lastDocWithoutPinned = pinnedFilters?.length + ? { + ...lastKnownDoc, + state: { + ...lastKnownDoc.state, + filters: appFilters, + }, + } + : lastKnownDoc; + + const doc = { + ...lastDocWithoutPinned, + id: saveProps.newCopyOnSave ? undefined : lastKnownDoc.id, + title: saveProps.newTitle, + }; + + const newlyCreated: boolean = saveProps.newCopyOnSave || !lastKnownDoc?.id; + docStorage + .save(doc) + .then(({ id }) => { + // Prevents unnecessary network request and disables save button + const newDoc = { ...doc, id }; + setState(s => ({ + ...s, + isSaveModalVisible: false, + persistedDoc: newDoc, + lastKnownDoc: newDoc, + })); + if (docId !== id || saveProps.returnToOrigin) { + redirectTo(id, saveProps.returnToOrigin, newlyCreated); + } + }) + .catch(e => { + // eslint-disable-next-line no-console + console.dir(e); + trackUiEvent('save_failed'); + core.notifications.toasts.addDanger( + i18n.translate('xpack.lens.app.docSavingError', { + defaultMessage: 'Error saving document', + }) + ); + setState(s => ({ ...s, isSaveModalVisible: false })); + }); + }; + const onError = useCallback( (e: { message: string }) => core.notifications.toasts.addDanger({ @@ -192,13 +251,6 @@ export function App({ const { TopNavMenu } = navigation.ui; - const confirmButton = addToDashboardMode ? ( - - ) : null; - return ( { + if (isSaveable && lastKnownDoc) { + runSave({ + newTitle: lastKnownDoc.title, + newCopyOnSave: false, + isTitleDuplicateConfirmed: false, + returnToOrigin: true, + }); + } + }, + testId: 'lnsApp_saveAndReturnButton', + disableButton: !isSaveable, + }, + ] + : []), { - label: i18n.translate('xpack.lens.app.save', { - defaultMessage: 'Save', - }), + label: + lastKnownDoc?.id && !!originatingApp + ? i18n.translate('xpack.lens.app.saveAs', { + defaultMessage: 'Save as', + }) + : i18n.translate('xpack.lens.app.save', { + defaultMessage: 'Save', + }), + emphasize: !originatingApp || !lastKnownDoc?.id, run: () => { if (isSaveable && lastKnownDoc) { setState(s => ({ ...s, isSaveModalVisible: true })); @@ -336,63 +417,18 @@ export function App({ )} {lastKnownDoc && state.isSaveModalVisible && ( - { - const [pinnedFilters, appFilters] = _.partition( - lastKnownDoc.state?.filters, - esFilters.isFilterPinned - ); - const lastDocWithoutPinned = pinnedFilters?.length - ? { - ...lastKnownDoc, - state: { - ...lastKnownDoc.state, - filters: appFilters, - }, - } - : lastKnownDoc; - - const doc = { - ...lastDocWithoutPinned, - id: props.newCopyOnSave ? undefined : lastKnownDoc.id, - title: props.newTitle, - }; - - docStorage - .save(doc) - .then(({ id }) => { - // Prevents unnecessary network request and disables save button - const newDoc = { ...doc, id }; - setState(s => ({ - ...s, - isSaveModalVisible: false, - persistedDoc: newDoc, - lastKnownDoc: newDoc, - })); - if (docId !== id) { - redirectTo(id); - } - }) - .catch(e => { - // eslint-disable-next-line no-console - console.dir(e); - trackUiEvent('save_failed'); - core.notifications.toasts.addDanger( - i18n.translate('xpack.lens.app.docSavingError', { - defaultMessage: 'Error saving document', - }) - ); - setState(s => ({ ...s, isSaveModalVisible: false })); - }); - }} + runSave(props)} onClose={() => setState(s => ({ ...s, isSaveModalVisible: false }))} - title={lastKnownDoc.title || ''} - showCopyOnSave={!!lastKnownDoc.id && !addToDashboardMode} + documentInfo={{ + id: lastKnownDoc.id, + title: lastKnownDoc.title || '', + }} objectType={i18n.translate('xpack.lens.app.saveModalType', { defaultMessage: 'Lens visualization', })} - showDescription={false} - confirmButtonLabel={confirmButton} + data-test-subj="lnsApp_saveModalOrigin" /> )} diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index f295f88a58e5f..74bc5821aa713 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -11,8 +11,8 @@ import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom import { render, unmountComponentAtNode } from 'react-dom'; import rison from 'rison-node'; -import { DashboardConstants } from '../../../../../src/plugins/dashboard/public'; -import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { parse } from 'query-string'; +import { Storage, removeQueryParam } from '../../../../../src/plugins/kibana_utils/public'; import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry'; @@ -52,33 +52,48 @@ export async function mountApp( }; const redirectTo = ( routeProps: RouteComponentProps<{ id?: string }>, - addToDashboardMode: boolean, - id?: string + originatingApp: string, + id?: string, + returnToOrigin?: boolean, + newlyCreated?: boolean ) => { + if (!!originatingApp && !returnToOrigin) { + removeQueryParam(routeProps.history, 'embeddableOriginatingApp'); + } + if (!id) { routeProps.history.push('/lens'); - } else if (!addToDashboardMode) { + } else if (!originatingApp) { routeProps.history.push(`/lens/edit/${id}`); - } else if (addToDashboardMode && id) { + } else if (!!originatingApp && id && returnToOrigin) { routeProps.history.push(`/lens/edit/${id}`); - const lastDashboardLink = coreStart.chrome.navLinks.get('kibana:dashboard'); - if (!lastDashboardLink || !lastDashboardLink.url) { - throw new Error('Cannot get last dashboard url'); + const originatingAppLink = coreStart.chrome.navLinks.get(originatingApp); + if (!originatingAppLink || !originatingAppLink.url) { + throw new Error('Cannot get originating app url'); + } + + // TODO: Remove this and use application.redirectTo after https://github.com/elastic/kibana/pull/63443 + if (originatingApp === 'kibana:dashboard') { + const addLensId = newlyCreated ? id : ''; + const urlVars = getUrlVars(originatingAppLink.url); + updateUrlTime(urlVars); // we need to pass in timerange in query params directly + const dashboardUrl = addEmbeddableToDashboardUrl( + originatingAppLink.url, + addLensId, + urlVars + ); + window.history.pushState({}, '', dashboardUrl); + } else { + window.location.href = originatingAppLink.url; } - const urlVars = getUrlVars(lastDashboardLink.url); - updateUrlTime(urlVars); // we need to pass in timerange in query params directly - const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardLink.url, id, urlVars); - window.history.pushState({}, '', dashboardUrl); } }; const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { trackUiEvent('loaded'); - const addToDashboardMode = - !!routeProps.location.search && - routeProps.location.search.includes( - DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM - ); + const urlParams = parse(routeProps.location.search) as Record; + const originatingApp = urlParams.embeddableOriginatingApp; + return ( redirectTo(routeProps, addToDashboardMode, id)} - addToDashboardMode={addToDashboardMode} + redirectTo={(id, returnToOrigin, newlyCreated) => + redirectTo(routeProps, originatingApp, id, returnToOrigin, newlyCreated) + } + originatingApp={originatingApp} /> ); }; diff --git a/x-pack/plugins/lens/public/assets/chart_donut.svg b/x-pack/plugins/lens/public/assets/chart_donut.svg new file mode 100644 index 0000000000000..5e0d8b7ea83bf --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_donut.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/lens/public/assets/chart_pie.svg b/x-pack/plugins/lens/public/assets/chart_pie.svg new file mode 100644 index 0000000000000..22faaf5d97661 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_pie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/lens/public/assets/chart_treemap.svg b/x-pack/plugins/lens/public/assets/chart_treemap.svg new file mode 100644 index 0000000000000..b0ee04d02b2a6 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_treemap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 21bbcce68bf36..ed0512ba220eb 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -115,8 +115,8 @@ export const datatableVisualization: Visualization< return [ { title, - // table with >= 10 columns will have a score of 0.6, fewer columns reduce score - score: (Math.min(table.columns.length, 10) / 10) * 0.6, + // table with >= 10 columns will have a score of 0.4, fewer columns reduce score + score: (Math.min(table.columns.length, 10) / 10) * 0.4, state: { layers: [ { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx index c8d8064e60e38..157a871e202f7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx @@ -22,10 +22,11 @@ describe('chart_switch', () => { return { ...createMockVisualization(), id, + getVisualizationTypeId: jest.fn(_state => id), visualizationTypes: [ { icon: 'empty', - id: `sub${id}`, + id, label: `Label ${id}`, }, ], @@ -51,6 +52,7 @@ describe('chart_switch', () => { visB: generateVisualization('visB'), visC: { ...generateVisualization('visC'), + initialize: jest.fn((_frame, state) => state ?? { type: 'subvisC1' }), visualizationTypes: [ { icon: 'empty', @@ -68,15 +70,23 @@ describe('chart_switch', () => { label: 'C3', }, ], + getVisualizationTypeId: jest.fn(state => state.type), getSuggestions: jest.fn(options => { if (options.subVisualizationId === 'subvisC2') { return []; } + // Multiple suggestions need to be filtered return [ + { + score: 1, + title: 'Primary suggestion', + state: { type: 'subvisC3' }, + previewIcon: 'empty', + }, { score: 1, title: '', - state: `suggestion`, + state: { type: 'subvisC1', notPrimary: true }, previewIcon: 'empty', }, ]; @@ -162,7 +172,7 @@ describe('chart_switch', () => { const component = mount( { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(dispatch).toHaveBeenCalledWith({ initialState: 'suggestion visB', @@ -201,7 +211,7 @@ describe('chart_switch', () => { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(frame.removeLayers).toHaveBeenCalledWith(['a']); @@ -265,7 +275,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + expect(getMenuItem('visB', component).prop('betaBadgeIconType')).toEqual('alert'); }); it('should indicate data loss if not all layers will be used', () => { @@ -285,7 +295,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + expect(getMenuItem('visB', component).prop('betaBadgeIconType')).toEqual('alert'); }); it('should indicate data loss if no data will be used', () => { @@ -306,7 +316,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + expect(getMenuItem('visB', component).prop('betaBadgeIconType')).toEqual('alert'); }); it('should not indicate data loss if there is no data', () => { @@ -328,7 +338,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toBeUndefined(); + expect(getMenuItem('visB', component).prop('betaBadgeIconType')).toBeUndefined(); }); it('should not show a warning when the subvisualization is the same', () => { @@ -336,14 +346,14 @@ describe('chart_switch', () => { const frame = mockFrame(['a', 'b', 'c']); const visualizations = mockVisualizations(); visualizations.visC.getVisualizationTypeId.mockReturnValue('subvisC2'); - const switchVisualizationType = jest.fn(() => 'therebedragons'); + const switchVisualizationType = jest.fn(() => ({ type: 'subvisC1' })); visualizations.visC.switchVisualizationType = switchVisualizationType; const component = mount( { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(frame.removeLayers).toHaveBeenCalledTimes(1); expect(frame.removeLayers).toHaveBeenCalledWith(['a', 'b', 'c']); @@ -403,7 +413,7 @@ describe('chart_switch', () => { const component = mount( { ); switchTo('subvisC3', component); - expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', 'suggestion'); + expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', { type: 'subvisC3' }); expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: 'SWITCH_VISUALIZATION', @@ -471,7 +481,7 @@ describe('chart_switch', () => { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(dispatch).toHaveBeenCalledWith({ type: 'SWITCH_VISUALIZATION', @@ -503,10 +513,10 @@ describe('chart_switch', () => { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(dispatch).toHaveBeenCalledWith({ - initialState: 'suggestion visB subvisB', + initialState: 'suggestion visB visB', newVisualizationId: 'visB', type: 'SWITCH_VISUALIZATION', datasourceId: 'testDatasource', @@ -514,6 +524,32 @@ describe('chart_switch', () => { }); }); + it('should use the suggestion that matches the subtype', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const switchVisualizationType = jest.fn(); + + visualizations.visC.switchVisualizationType = switchVisualizationType; + + const component = mount( + + ); + + switchTo('subvisC1', component); + expect(switchVisualizationType).toHaveBeenCalledWith('subvisC1', { + type: 'subvisC1', + notPrimary: true, + }); + }); + it('should show all visualization types', () => { const component = mount( { showFlyout(component); - const allDisplayed = ['subvisA', 'subvisB', 'subvisC1', 'subvisC2'].every( + const allDisplayed = ['visA', 'visB', 'subvisC1', 'subvisC2', 'subvisC3'].every( subType => component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0 ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx index d73f83e75c0e4..81eb82dfdbab4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx @@ -272,7 +272,10 @@ function getTopSuggestion( }).filter(suggestion => { // don't use extended versions of current data table on switching between visualizations // to avoid confusing the user. - return suggestion.changeType !== 'extended'; + return ( + suggestion.changeType !== 'extended' && + newVisualization.getVisualizationTypeId(suggestion.visualizationState) === subVisualizationId + ); }); // We prefer unchanged or reduced suggestions when switching diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx index 57588e31590b4..49f2224bf4231 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx @@ -5,7 +5,7 @@ */ import React, { useState } from 'react'; -import { EuiPopover, EuiButtonIcon } from '@elastic/eui'; +import { EuiPopover, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { NativeRenderer } from '../../../native_renderer'; import { Visualization, VisualizationLayerWidgetProps } from '../../../types'; @@ -28,21 +28,27 @@ export function LayerSettings({ return ( setIsOpen(!isOpen)} - data-test-subj="lns_layer_settings" - /> + > + setIsOpen(!isOpen)} + data-test-subj="lns_layer_settings" + /> + } isOpen={isOpen} closePopover={() => setIsOpen(false)} - anchorPosition="leftUp" + anchorPosition="downLeft" > { it('should use suggestions to switch to new visualization', async () => { const initialState = { suggested: true }; mockVisualization2.initialize.mockReturnValueOnce({ initial: true }); + mockVisualization2.getVisualizationTypeId.mockReturnValueOnce('testVis2'); mockVisualization2.getSuggestions.mockReturnValueOnce([ { title: 'Suggested vis', diff --git a/x-pack/plugins/lens/public/helpers/url_helper.ts b/x-pack/plugins/lens/public/helpers/url_helper.ts index 0a97ba4b2edf7..2ffc381c4f62f 100644 --- a/x-pack/plugins/lens/public/helpers/url_helper.ts +++ b/x-pack/plugins/lens/public/helpers/url_helper.ts @@ -37,8 +37,10 @@ export function addEmbeddableToDashboardUrl(url: string, embeddableId: string, u keys.forEach(key => { dashboardParsedUrl.query[key] = urlVars[key]; }); - dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = 'lens'; - dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + if (embeddableId) { + dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = 'lens'; + dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + } const query = stringify(dashboardParsedUrl.query); return `${dashboardParsedUrl.url}?${query}`; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 06635e663361d..d8449143b569f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -272,10 +272,10 @@ describe('IndexPattern Data Source', () => { "1", ], "metricsAtAllLevels": Array [ - false, + true, ], "partialRows": Array [ - false, + true, ], "timeFields": Array [ "timestamp", @@ -287,7 +287,7 @@ describe('IndexPattern Data Source', () => { Object { "arguments": Object { "idMap": Array [ - "{\\"col-0-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", + "{\\"col--1-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-2-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", ], }, "function": "lens_rename_columns", diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 1308fa3b7ca60..1dde03ca8ee9b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -26,17 +26,35 @@ function getExpressionForLayer( } const columnEntries = columnOrder.map(colId => [colId, columns[colId]] as const); + const bucketsCount = columnEntries.filter(([, entry]) => entry.isBucketed).length; + const metricsCount = columnEntries.length - bucketsCount; if (columnEntries.length) { const aggs = columnEntries.map(([colId, col]) => { return getEsAggsConfig(col, colId); }); - const idMap = columnEntries.reduce((currentIdMap, [colId], index) => { + /** + * Because we are turning on metrics at all levels, the sequence generation + * logic here is more complicated. Examples follow: + * + * Example 1: [Count] + * Output: [`col-0-count`] + * + * Example 2: [Terms, Terms, Count] + * Output: [`col-0-terms0`, `col-2-terms1`, `col-3-count`] + * + * Example 3: [Terms, Terms, Count, Max] + * Output: [`col-0-terms0`, `col-3-terms1`, `col-4-count`, `col-5-max`] + */ + const idMap = columnEntries.reduce((currentIdMap, [colId, column], index) => { + const newIndex = column.isBucketed + ? index * (metricsCount + 1) // Buckets are spaced apart by N + 1 + : (index ? index + 1 : 0) - bucketsCount + (bucketsCount - 1) * (metricsCount + 1); return { ...currentIdMap, - [`col-${index}-${colId}`]: { - ...columns[colId], + [`col-${columnEntries.length === 1 ? 0 : newIndex}-${colId}`]: { + ...column, id: colId, }, }; @@ -83,8 +101,8 @@ function getExpressionForLayer( function: 'esaggs', arguments: { index: [indexPattern.id], - metricsAtAllLevels: [false], - partialRows: [false], + metricsAtAllLevels: [true], + partialRows: [true], includeFormatHints: [true], timeFields: allDateHistogramFields, aggConfigs: [JSON.stringify(aggs)], diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts index ef93f0b5bf064..173119714189d 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts @@ -101,7 +101,7 @@ describe('metric_suggestions', () => { expect(suggestion).toMatchInlineSnapshot(` Object { "previewIcon": "test-file-stub", - "score": 0.5, + "score": 0.1, "state": Object { "accessor": "bytes", "layerId": "l1", diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts index 23df3f55f2777..0caac7dd0d092 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts @@ -43,7 +43,7 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion { return { title, - score: 0.5, + score: 0.1, previewIcon: chartMetricSVG, state: { layerId: table.layerId, diff --git a/x-pack/plugins/lens/public/pie_visualization/constants.ts b/x-pack/plugins/lens/public/pie_visualization/constants.ts new file mode 100644 index 0000000000000..10672f91a81c7 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/constants.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import chartDonutSVG from '../assets/chart_donut.svg'; +import chartPieSVG from '../assets/chart_pie.svg'; +import chartTreemapSVG from '../assets/chart_treemap.svg'; + +export const CHART_NAMES = { + donut: { + icon: chartDonutSVG, + label: i18n.translate('xpack.lens.pie.donutLabel', { + defaultMessage: 'Donut', + }), + }, + pie: { + icon: chartPieSVG, + label: i18n.translate('xpack.lens.pie.pielabel', { + defaultMessage: 'Pie', + }), + }, + treemap: { + icon: chartTreemapSVG, + label: i18n.translate('xpack.lens.pie.treemaplabel', { + defaultMessage: 'Treemap', + }), + }, +}; + +export const MAX_PIE_BUCKETS = 3; +export const MAX_TREEMAP_BUCKETS = 2; + +export const DEFAULT_PERCENT_DECIMALS = 3; diff --git a/x-pack/plugins/lens/public/pie_visualization/index.ts b/x-pack/plugins/lens/public/pie_visualization/index.ts new file mode 100644 index 0000000000000..b2aae2e8529a5 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/index.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; +import { CoreSetup, CoreStart } from 'src/core/public'; +import { ExpressionsSetup } from 'src/plugins/expressions/public'; +import { pieVisualization } from './pie_visualization'; +import { pie, getPieRenderer } from './register_expression'; +import { EditorFrameSetup, FormatFactory } from '../types'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { setExecuteTriggerActions } from '../services'; + +export interface PieVisualizationPluginSetupPlugins { + editorFrame: EditorFrameSetup; + expressions: ExpressionsSetup; + formatFactory: Promise; +} + +export interface PieVisualizationPluginStartPlugins { + uiActions: UiActionsStart; +} + +export class PieVisualization { + constructor() {} + + setup( + core: CoreSetup, + { expressions, formatFactory, editorFrame }: PieVisualizationPluginSetupPlugins + ) { + expressions.registerFunction(() => pie); + + expressions.registerRenderer( + getPieRenderer({ + formatFactory, + chartTheme: core.uiSettings.get('theme:darkMode') + ? EUI_CHARTS_THEME_DARK.theme + : EUI_CHARTS_THEME_LIGHT.theme, + isDarkMode: core.uiSettings.get('theme:darkMode'), + }) + ); + + editorFrame.registerVisualization(pieVisualization); + } + + start(core: CoreStart, { uiActions }: PieVisualizationPluginStartPlugins) { + setExecuteTriggerActions(uiActions.executeTriggerActions); + } + + stop() {} +} diff --git a/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx new file mode 100644 index 0000000000000..78e13bc51588c --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx @@ -0,0 +1,215 @@ +/* + * 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 { render } from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Visualization, OperationMetadata } from '../types'; +import { toExpression, toPreviewExpression } from './to_expression'; +import { LayerState, PieVisualizationState } from './types'; +import { suggestions } from './suggestions'; +import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; +import { SettingsWidget } from './settings_widget'; + +function newLayerState(layerId: string): LayerState { + return { + layerId, + groups: [], + metric: undefined, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + }; +} + +const bucketedOperations = (op: OperationMetadata) => op.isBucketed; +const numberMetricOperations = (op: OperationMetadata) => + !op.isBucketed && op.dataType === 'number'; + +export const pieVisualization: Visualization = { + id: 'lnsPie', + + visualizationTypes: [ + { + id: 'donut', + largeIcon: CHART_NAMES.donut.icon, + label: CHART_NAMES.donut.label, + }, + { + id: 'pie', + largeIcon: CHART_NAMES.pie.icon, + label: CHART_NAMES.pie.label, + }, + { + id: 'treemap', + largeIcon: CHART_NAMES.treemap.icon, + label: CHART_NAMES.treemap.label, + }, + ], + + getVisualizationTypeId(state) { + return state.shape; + }, + + getLayerIds(state) { + return state.layers.map(l => l.layerId); + }, + + clearLayer(state) { + return { + shape: state.shape, + layers: state.layers.map(l => newLayerState(l.layerId)), + }; + }, + + getDescription(state) { + if (state.shape === 'treemap') { + return CHART_NAMES.treemap; + } + if (state.shape === 'donut') { + return CHART_NAMES.donut; + } + return CHART_NAMES.pie; + }, + + switchVisualizationType: (visualizationTypeId, state) => ({ + ...state, + shape: visualizationTypeId as PieVisualizationState['shape'], + }), + + initialize(frame, state) { + return ( + state || { + shape: 'donut', + layers: [newLayerState(frame.addNewLayer())], + } + ); + }, + + getPersistableState: state => state, + + getSuggestions: suggestions, + + getConfiguration({ state, frame, layerId }) { + const layer = state.layers.find(l => l.layerId === layerId); + if (!layer) { + return { groups: [] }; + } + + const datasource = frame.datasourceLayers[layer.layerId]; + const originalOrder = datasource + .getTableSpec() + .map(({ columnId }) => columnId) + .filter(columnId => columnId !== layer.metric); + // When we add a column it could be empty, and therefore have no order + const sortedColumns = Array.from(new Set(originalOrder.concat(layer.groups))); + + if (state.shape === 'treemap') { + return { + groups: [ + { + groupId: 'groups', + groupLabel: i18n.translate('xpack.lens.pie.treemapGroupLabel', { + defaultMessage: 'Group by', + }), + layerId, + accessors: sortedColumns, + supportsMoreColumns: sortedColumns.length < MAX_TREEMAP_BUCKETS, + filterOperations: bucketedOperations, + required: true, + }, + { + groupId: 'metric', + groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { + defaultMessage: 'Size by', + }), + layerId, + accessors: layer.metric ? [layer.metric] : [], + supportsMoreColumns: !layer.metric, + filterOperations: numberMetricOperations, + required: true, + }, + ], + }; + } + + return { + groups: [ + { + groupId: 'groups', + groupLabel: i18n.translate('xpack.lens.pie.sliceGroupLabel', { + defaultMessage: 'Slice by', + }), + layerId, + accessors: sortedColumns, + supportsMoreColumns: sortedColumns.length < MAX_PIE_BUCKETS, + filterOperations: bucketedOperations, + required: true, + }, + { + groupId: 'metric', + groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { + defaultMessage: 'Size by', + }), + layerId, + accessors: layer.metric ? [layer.metric] : [], + supportsMoreColumns: !layer.metric, + filterOperations: numberMetricOperations, + required: true, + }, + ], + }; + }, + + setDimension({ prevState, layerId, columnId, groupId }) { + return { + ...prevState, + + shape: + prevState.shape === 'donut' && prevState.layers.every(l => l.groups.length === 1) + ? 'pie' + : prevState.shape, + layers: prevState.layers.map(l => { + if (l.layerId !== layerId) { + return l; + } + if (groupId === 'groups') { + return { ...l, groups: [...l.groups, columnId] }; + } + return { ...l, metric: columnId }; + }), + }; + }, + removeDimension({ prevState, layerId, columnId }) { + return { + ...prevState, + layers: prevState.layers.map(l => { + if (l.layerId !== layerId) { + return l; + } + + if (l.metric === columnId) { + return { ...l, metric: undefined }; + } + return { ...l, groups: l.groups.filter(c => c !== columnId) }; + }), + }; + }, + + toExpression, + toPreviewExpression, + + renderLayerContextMenu(domElement, props) { + render( + + + , + domElement + ); + }, +}; diff --git a/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx b/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx new file mode 100644 index 0000000000000..998d2162f7f5d --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { PartialTheme } from '@elastic/charts'; +import { + IInterpreterRenderHandlers, + ExpressionRenderDefinition, + ExpressionFunctionDefinition, +} from 'src/plugins/expressions/public'; +import { LensMultiTable, FormatFactory } from '../types'; +import { PieExpressionProps, PieExpressionArgs } from './types'; +import { getExecuteTriggerActions } from '../services'; +import { PieComponent } from './render_function'; + +export interface PieRender { + type: 'render'; + as: 'lens_pie_renderer'; + value: PieExpressionProps; +} + +export const pie: ExpressionFunctionDefinition< + 'lens_pie', + LensMultiTable, + PieExpressionArgs, + PieRender +> = { + name: 'lens_pie', + type: 'render', + help: i18n.translate('xpack.lens.pie.expressionHelpLabel', { + defaultMessage: 'Pie renderer', + }), + args: { + groups: { + types: ['string'], + multi: true, + help: '', + }, + metric: { + types: ['string'], + help: '', + }, + shape: { + types: ['string'], + options: ['pie', 'donut', 'treemap'], + help: '', + }, + hideLabels: { + types: ['boolean'], + help: '', + }, + numberDisplay: { + types: ['string'], + options: ['hidden', 'percent', 'value'], + help: '', + }, + categoryDisplay: { + types: ['string'], + options: ['default', 'inside', 'hide'], + help: '', + }, + legendDisplay: { + types: ['string'], + options: ['default', 'show', 'hide'], + help: '', + }, + nestedLegend: { + types: ['boolean'], + help: '', + }, + percentDecimals: { + types: ['number'], + help: '', + }, + }, + inputTypes: ['lens_multitable'], + fn(data: LensMultiTable, args: PieExpressionArgs) { + return { + type: 'render', + as: 'lens_pie_renderer', + value: { + data, + args, + }, + }; + }, +}; + +export const getPieRenderer = (dependencies: { + formatFactory: Promise; + chartTheme: PartialTheme; + isDarkMode: boolean; +}): ExpressionRenderDefinition => ({ + name: 'lens_pie_renderer', + displayName: i18n.translate('xpack.lens.pie.visualizationName', { + defaultMessage: 'Pie', + }), + help: '', + validate: () => undefined, + reuseDomNode: true, + render: async ( + domNode: Element, + config: PieExpressionProps, + handlers: IInterpreterRenderHandlers + ) => { + const executeTriggerActions = getExecuteTriggerActions(); + const formatFactory = await dependencies.formatFactory; + ReactDOM.render( + , + domNode, + () => { + handlers.done(); + } + ); + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); + +const MemoizedChart = React.memo(PieComponent); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx new file mode 100644 index 0000000000000..bdc8004540bae --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 { Settings } from '@elastic/charts'; +import { shallow } from 'enzyme'; +import { LensMultiTable } from '../types'; +import { PieComponent } from './render_function'; +import { PieExpressionArgs } from './types'; + +describe('PieVisualization component', () => { + let getFormatSpy: jest.Mock; + let convertSpy: jest.Mock; + + beforeEach(() => { + convertSpy = jest.fn(x => x); + getFormatSpy = jest.fn(); + getFormatSpy.mockReturnValue({ convert: convertSpy }); + }); + + describe('legend options', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + ], + rows: [ + { a: 6, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + + const args: PieExpressionArgs = { + shape: 'pie', + groups: ['a', 'b'], + metric: 'c', + numberDisplay: 'hidden', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + percentDecimals: 3, + hideLabels: false, + }; + + function getDefaultArgs() { + return { + data, + formatFactory: getFormatSpy, + isDarkMode: false, + chartTheme: {}, + executeTriggerActions: jest.fn(), + }; + } + + test('it shows legend for 2 groups using default legendDisplay', () => { + const component = shallow(); + expect(component.find(Settings).prop('showLegend')).toEqual(true); + }); + + test('it hides legend for 1 group using default legendDisplay', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + + test('it hides legend that would show otherwise in preview mode', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + + test('it hides legend with 2 groups for treemap', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + + test('it shows treemap legend only when forced on', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('showLegend')).toEqual(true); + }); + + test('it defaults to 1-level legend depth', () => { + const component = shallow(); + expect(component.find(Settings).prop('legendMaxDepth')).toEqual(1); + }); + + test('it shows nested legend only when forced on', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx new file mode 100644 index 0000000000000..f451a6b74e299 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -0,0 +1,259 @@ +/* + * 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, useEffect } from 'react'; +import color from 'color'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText } from '@elastic/eui'; +// @ts-ignore no types +import { euiPaletteColorBlindBehindText } from '@elastic/eui/lib/services'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { + Chart, + Datum, + Settings, + Partition, + PartitionConfig, + PartitionLayer, + PartitionLayout, + PartialTheme, + PartitionFillLabel, + RecursivePartial, + LayerValue, +} from '@elastic/charts'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; +import { FormatFactory } from '../types'; +import { VisualizationContainer } from '../visualization_container'; +import { CHART_NAMES, DEFAULT_PERCENT_DECIMALS } from './constants'; +import { ColumnGroups, PieExpressionProps } from './types'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { getSliceValueWithFallback, getFilterContext } from './render_helpers'; +import './visualization.scss'; + +const EMPTY_SLICE = Symbol('empty_slice'); + +const sortedColors = euiPaletteColorBlindBehindText(); + +export function PieComponent( + props: PieExpressionProps & { + formatFactory: FormatFactory; + chartTheme: Exclude; + isDarkMode: boolean; + executeTriggerActions: UiActionsStart['executeTriggerActions']; + } +) { + const [firstTable] = Object.values(props.data.tables); + const formatters: Record> = {}; + + const { chartTheme, isDarkMode, executeTriggerActions } = props; + const { + shape, + groups, + metric, + numberDisplay, + categoryDisplay, + legendDisplay, + nestedLegend, + percentDecimals, + hideLabels, + } = props.args; + + if (!hideLabels) { + firstTable.columns.forEach(column => { + formatters[column.id] = props.formatFactory(column.formatHint); + }); + } + + // The datatable for pie charts should include subtotals, like this: + // [bucket, subtotal, bucket, count] + // But the user only configured [bucket, bucket, count] + const columnGroups: ColumnGroups = []; + firstTable.columns.forEach(col => { + if (groups.includes(col.id)) { + columnGroups.push({ + col, + metrics: [], + }); + } else if (columnGroups.length > 0) { + columnGroups[columnGroups.length - 1].metrics.push(col); + } + }); + + const fillLabel: Partial = { + textInvertible: false, + valueFont: { + fontWeight: 700, + }, + }; + + if (numberDisplay === 'hidden') { + // Hides numbers from appearing inside chart, but they still appear in linkLabel + // and tooltips. + fillLabel.valueFormatter = () => ''; + } + + const layers: PartitionLayer[] = columnGroups.map(({ col }, layerIndex) => { + return { + groupByRollup: (d: Datum) => d[col.id] ?? EMPTY_SLICE, + showAccessor: (d: Datum) => d !== EMPTY_SLICE, + nodeLabel: (d: unknown) => { + if (hideLabels || d === EMPTY_SLICE) { + return ''; + } + if (col.formatHint) { + return formatters[col.id].convert(d) ?? ''; + } + return String(d); + }, + fillLabel: + isDarkMode && shape === 'treemap' && layerIndex < columnGroups.length - 1 + ? { ...fillLabel, textColor: euiDarkVars.euiTextColor } + : fillLabel, + shape: { + fillColor: d => { + // Color is determined by round-robin on the index of the innermost slice + // This has to be done recursively until we get to the slice index + let parentIndex = 0; + let tempParent: typeof d | typeof d['parent'] = d; + while (tempParent.parent && tempParent.depth > 0) { + parentIndex = tempParent.sortIndex; + tempParent = tempParent.parent; + } + + // Look up round-robin color from default palette + const outputColor = sortedColors[parentIndex % sortedColors.length]; + + if (shape === 'treemap') { + // Only highlight the innermost color of the treemap, as it accurately represents area + return layerIndex < columnGroups.length - 1 ? 'rgba(0,0,0,0)' : outputColor; + } + + const lighten = (d.depth - 1) / (columnGroups.length * 2); + return color(outputColor, 'hsl') + .lighten(lighten) + .hex(); + }, + }, + }; + }); + + const config: RecursivePartial = { + partitionLayout: shape === 'treemap' ? PartitionLayout.treemap : PartitionLayout.sunburst, + fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, + outerSizeRatio: 1, + specialFirstInnermostSector: true, + clockwiseSectors: false, + minFontSize: 10, + maxFontSize: 16, + // Labels are added outside the outer ring when the slice is too small + linkLabel: { + maxCount: 5, + fontSize: 11, + // Dashboard background color is affected by dark mode, which we need + // to account for in outer labels + // This does not handle non-dashboard embeddables, which are allowed to + // have different backgrounds. + textColor: chartTheme.axes?.axisTitleStyle?.fill, + }, + sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill, + sectorLineWidth: 1.5, + circlePadding: 4, + }; + if (shape === 'treemap') { + if (hideLabels || categoryDisplay === 'hide') { + config.fillLabel = { textColor: 'rgba(0,0,0,0)' }; + } + } else { + config.emptySizeRatio = shape === 'donut' ? 0.3 : 0; + + if (hideLabels || categoryDisplay === 'hide') { + // Force all labels to be linked, then prevent links from showing + config.linkLabel = { maxCount: 0, maximumSection: Number.POSITIVE_INFINITY }; + } else if (categoryDisplay === 'inside') { + // Prevent links from showing + config.linkLabel = { maxCount: 0 }; + } + } + const metricColumn = firstTable.columns.find(c => c.id === metric)!; + const percentFormatter = props.formatFactory({ + id: 'percent', + params: { + pattern: `0,0.${'0'.repeat(percentDecimals ?? DEFAULT_PERCENT_DECIMALS)}%`, + }, + }); + + const [state, setState] = useState({ isReady: false }); + // It takes a cycle for the chart to render. This prevents + // reporting from printing a blank chart placeholder. + useEffect(() => { + setState({ isReady: true }); + }, []); + + const reverseGroups = [...columnGroups].reverse(); + + const hasNegative = firstTable.rows.some(row => { + const value = row[metricColumn.id]; + return typeof value === 'number' && value < 0; + }); + if (firstTable.rows.length === 0 || hasNegative) { + return ( + + {hasNegative ? ( + + ) : ( + + )} + + ); + } + + return ( + + + + + ); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts new file mode 100644 index 0000000000000..824eec63ba118 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts @@ -0,0 +1,183 @@ +/* + * 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 { KibanaDatatable } from 'src/plugins/expressions/public'; +import { getSliceValueWithFallback, getFilterContext } from './render_helpers'; + +describe('render helpers', () => { + describe('#getSliceValueWithFallback', () => { + describe('without fallback', () => { + const columnGroups = [ + { col: { id: 'a', name: 'A' }, metrics: [] }, + { col: { id: 'b', name: 'C' }, metrics: [] }, + ]; + + it('returns the metric when positive number', () => { + expect( + getSliceValueWithFallback({ a: 'Cat', b: 'Home', c: 5 }, columnGroups, { + id: 'c', + name: 'C', + }) + ).toEqual(5); + }); + + it('returns the metric when negative number', () => { + expect( + getSliceValueWithFallback({ a: 'Cat', b: 'Home', c: -100 }, columnGroups, { + id: 'c', + name: 'C', + }) + ).toEqual(-100); + }); + + it('returns epsilon when metric is 0 without fallback', () => { + expect( + getSliceValueWithFallback({ a: 'Cat', b: 'Home', c: 0 }, columnGroups, { + id: 'c', + name: 'C', + }) + ).toEqual(Number.EPSILON); + }); + }); + + describe('fallback behavior', () => { + const columnGroups = [ + { col: { id: 'a', name: 'A' }, metrics: [{ id: 'a_subtotal', name: '' }] }, + { col: { id: 'b', name: 'C' }, metrics: [] }, + ]; + + it('falls back to metric from previous column if available', () => { + expect( + getSliceValueWithFallback( + { a: 'Cat', a_subtotal: 5, b: 'Home', c: undefined }, + columnGroups, + { id: 'c', name: 'C' } + ) + ).toEqual(5); + }); + + it('uses epsilon if fallback is 0', () => { + expect( + getSliceValueWithFallback( + { a: 'Cat', a_subtotal: 0, b: 'Home', c: undefined }, + columnGroups, + { id: 'c', name: 'C' } + ) + ).toEqual(Number.EPSILON); + }); + + it('uses epsilon if fallback is missing', () => { + expect( + getSliceValueWithFallback( + { a: 'Cat', a_subtotal: undefined, b: 'Home', c: undefined }, + columnGroups, + { id: 'c', name: 'C' } + ) + ).toEqual(Number.EPSILON); + }); + }); + }); + + describe('#getFilterContext', () => { + it('handles single slice click for single ring', () => { + const table: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + rows: [ + { a: 'Hi', b: 2 }, + { a: 'Test', b: 4 }, + { a: 'Foo', b: 6 }, + ], + }; + expect(getFilterContext([{ groupByRollup: 'Test', value: 100 }], ['a'], table)).toEqual({ + data: { + data: [ + { + row: 1, + column: 0, + value: 'Test', + table, + }, + ], + }, + }); + }); + + it('handles single slice click with 2 rings', () => { + const table: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + ], + rows: [ + { a: 'Hi', b: 'Two', c: 2 }, + { a: 'Test', b: 'Two', c: 5 }, + { a: 'Foo', b: 'Three', c: 6 }, + ], + }; + expect(getFilterContext([{ groupByRollup: 'Test', value: 100 }], ['a', 'b'], table)).toEqual({ + data: { + data: [ + { + row: 1, + column: 0, + value: 'Test', + table, + }, + ], + }, + }); + }); + + it('finds right row for multi slice click', () => { + const table: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + ], + rows: [ + { a: 'Hi', b: 'Two', c: 2 }, + { a: 'Test', b: 'Two', c: 5 }, + { a: 'Foo', b: 'Three', c: 6 }, + ], + }; + expect( + getFilterContext( + [ + { groupByRollup: 'Test', value: 100 }, + { groupByRollup: 'Two', value: 5 }, + ], + ['a', 'b'], + table + ) + ).toEqual({ + data: { + data: [ + { + row: 1, + column: 0, + value: 'Test', + table, + }, + { + row: 1, + column: 1, + value: 'Two', + table, + }, + ], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts new file mode 100644 index 0000000000000..bc3c29ba0fff1 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts @@ -0,0 +1,49 @@ +/* + * 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 { Datum, LayerValue } from '@elastic/charts'; +import { KibanaDatatable, KibanaDatatableColumn } from 'src/plugins/expressions/public'; +import { ValueClickTriggerContext } from '../../../../../src/plugins/embeddable/public'; +import { ColumnGroups } from './types'; + +export function getSliceValueWithFallback( + d: Datum, + reverseGroups: ColumnGroups, + metricColumn: KibanaDatatableColumn +) { + if (typeof d[metricColumn.id] === 'number' && d[metricColumn.id] !== 0) { + return d[metricColumn.id]; + } + // Sometimes there is missing data for outer groups + // When there is missing data, we fall back to the next groups + // This creates a sunburst effect + const hasMetric = reverseGroups.find(group => group.metrics.length && d[group.metrics[0].id]); + return hasMetric ? d[hasMetric.metrics[0].id] || Number.EPSILON : Number.EPSILON; +} + +export function getFilterContext( + clickedLayers: LayerValue[], + layerColumnIds: string[], + table: KibanaDatatable +): ValueClickTriggerContext { + const matchingIndex = table.rows.findIndex(row => + clickedLayers.every((layer, index) => { + const columnId = layerColumnIds[index]; + return row[columnId] === layer.groupByRollup; + }) + ); + + return { + data: { + data: clickedLayers.map((clickedLayer, index) => ({ + column: table.columns.findIndex(col => col.id === layerColumnIds[index]), + row: matchingIndex, + value: clickedLayer.groupByRollup, + table, + })), + }, + }; +} diff --git a/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss b/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss new file mode 100644 index 0000000000000..4fa328d8a904d --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss @@ -0,0 +1,3 @@ +.lnsPieSettingsWidget { + min-width: $euiSizeXL * 10; +} diff --git a/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx b/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx new file mode 100644 index 0000000000000..5a02b91efc749 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx @@ -0,0 +1,212 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + EuiForm, + EuiFormRow, + EuiSuperSelect, + EuiRange, + EuiSwitch, + EuiHorizontalRule, + EuiSpacer, + EuiButtonGroup, +} from '@elastic/eui'; +import { DEFAULT_PERCENT_DECIMALS } from './constants'; +import { PieVisualizationState, SharedLayerState } from './types'; +import { VisualizationLayerWidgetProps } from '../types'; +import './settings_widget.scss'; + +const numberOptions: Array<{ value: SharedLayerState['numberDisplay']; inputDisplay: string }> = [ + { + value: 'hidden', + inputDisplay: i18n.translate('xpack.lens.pieChart.hiddenNumbersLabel', { + defaultMessage: 'Hide from chart', + }), + }, + { + value: 'percent', + inputDisplay: i18n.translate('xpack.lens.pieChart.showPercentValuesLabel', { + defaultMessage: 'Show percent', + }), + }, + { + value: 'value', + inputDisplay: i18n.translate('xpack.lens.pieChart.showFormatterValuesLabel', { + defaultMessage: 'Show value', + }), + }, +]; + +const categoryOptions: Array<{ + value: SharedLayerState['categoryDisplay']; + inputDisplay: string; +}> = [ + { + value: 'default', + inputDisplay: i18n.translate('xpack.lens.pieChart.showCategoriesLabel', { + defaultMessage: 'Inside or outside', + }), + }, + { + value: 'inside', + inputDisplay: i18n.translate('xpack.lens.pieChart.fitInsideOnlyLabel', { + defaultMessage: 'Inside only', + }), + }, + { + value: 'hide', + inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { + defaultMessage: 'Hide labels', + }), + }, +]; + +const legendOptions: Array<{ + value: SharedLayerState['legendDisplay']; + label: string; + id: string; +}> = [ + { + id: 'pieLegendDisplay-default', + value: 'default', + label: i18n.translate('xpack.lens.pieChart.defaultLegendLabel', { + defaultMessage: 'auto', + }), + }, + { + id: 'pieLegendDisplay-show', + value: 'show', + label: i18n.translate('xpack.lens.pieChart.alwaysShowLegendLabel', { + defaultMessage: 'show', + }), + }, + { + id: 'pieLegendDisplay-hide', + value: 'hide', + label: i18n.translate('xpack.lens.pieChart.hideLegendLabel', { + defaultMessage: 'hide', + }), + }, +]; + +export function SettingsWidget(props: VisualizationLayerWidgetProps) { + const { state, setState } = props; + const layer = state.layers[0]; + if (!layer) { + return null; + } + + return ( + + + { + setState({ + ...state, + layers: [{ ...layer, categoryDisplay: option }], + }); + }} + /> + + + { + setState({ + ...state, + layers: [{ ...layer, numberDisplay: option }], + }); + }} + /> + + + + { + setState({ + ...state, + layers: [{ ...layer, percentDecimals: Number(e.currentTarget.value) }], + }); + }} + /> + + + +
+ value === layer.legendDisplay)!.id} + onChange={optionId => { + setState({ + ...state, + layers: [ + { + ...layer, + legendDisplay: legendOptions.find(({ id }) => id === optionId)!.value, + }, + ], + }); + }} + buttonSize="compressed" + isFullWidth + /> + + + { + setState({ ...state, layers: [{ ...layer, nestedLegend: !layer.nestedLegend }] }); + }} + /> +
+
+
+ ); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts new file mode 100644 index 0000000000000..7935d53f56845 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -0,0 +1,522 @@ +/* + * 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 { DataType } from '../types'; +import { suggestions } from './suggestions'; + +describe('suggestions', () => { + describe('pie', () => { + it('should reject multiple layer suggestions', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first', 'second'], + }) + ).toHaveLength(0); + }); + + it('should reject when layer is different', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['second'], + }) + ).toHaveLength(0); + }); + + it('should reject when currently active and unchanged data', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged', + }, + state: { + shape: 'pie', + layers: [ + { + layerId: 'first', + groups: [], + metric: 'a', + numberDisplay: 'hidden', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when table is reordered', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'reorder', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject any date operations', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'b', + operation: { label: 'Days', dataType: 'date' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are no buckets', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are no metrics', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: true }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are too many buckets', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are too many metrics', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should suggest a donut chart as initial state when only one bucket', () => { + const results = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + + expect(results).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ shape: 'donut' }), + }) + ); + }); + + it('should suggest a pie chart as initial state when more than one bucket', () => { + const results = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + + expect(results).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ shape: 'pie' }), + }) + ); + }); + + it('should keep the layer settings when switching from treemap', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'unchanged', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: ['a'], + metric: 'b', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + nestedLegend: true, + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toContainEqual( + expect.objectContaining({ + state: { + shape: 'donut', + layers: [ + { + layerId: 'first', + groups: ['a'], + metric: 'b', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + nestedLegend: true, + }, + ], + }, + }) + ); + }); + }); + + describe('treemap', () => { + it('should reject when currently active and unchanged data', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: [], + metric: 'a', + + numberDisplay: 'hidden', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are too many buckets being added', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'extended', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: ['a', 'b'], + metric: 'e', + numberDisplay: 'value', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are too many metrics', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: ['a', 'b'], + metric: 'e', + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should keep the layer settings when switching from pie', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'unchanged', + }, + state: { + shape: 'pie', + layers: [ + { + layerId: 'first', + groups: ['a'], + metric: 'b', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + nestedLegend: true, + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toContainEqual( + expect.objectContaining({ + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: ['a'], + metric: 'b', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + nestedLegend: true, + }, + ], + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts new file mode 100644 index 0000000000000..e363cf922b356 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -0,0 +1,137 @@ +/* + * 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 { partition } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { SuggestionRequest, VisualizationSuggestion } from '../types'; +import { PieVisualizationState } from './types'; +import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; + +function shouldReject({ table, keptLayerIds }: SuggestionRequest) { + return ( + keptLayerIds.length > 1 || + (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || + table.changeType === 'reorder' || + table.columns.some(col => col.operation.dataType === 'date') + ); +} + +export function suggestions({ + table, + state, + keptLayerIds, +}: SuggestionRequest): Array< + VisualizationSuggestion +> { + if (shouldReject({ table, state, keptLayerIds })) { + return []; + } + + const [groups, metrics] = partition(table.columns, col => col.operation.isBucketed); + + if ( + groups.length === 0 || + metrics.length !== 1 || + groups.length > Math.max(MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS) + ) { + return []; + } + + const results: Array> = []; + + if (groups.length <= MAX_PIE_BUCKETS) { + let newShape: PieVisualizationState['shape'] = 'donut'; + if (groups.length !== 1) { + newShape = 'pie'; + } + + const baseSuggestion: VisualizationSuggestion = { + title: i18n.translate('xpack.lens.pie.suggestionLabel', { + defaultMessage: 'As {chartName}', + values: { chartName: CHART_NAMES[newShape].label }, + description: 'chartName is already translated', + }), + score: state && state.shape !== 'treemap' ? 0.6 : 0.4, + state: { + shape: newShape, + layers: [ + state?.layers[0] + ? { + ...state.layers[0], + layerId: table.layerId, + groups: groups.map(col => col.columnId), + metric: metrics[0].columnId, + } + : { + layerId: table.layerId, + groups: groups.map(col => col.columnId), + metric: metrics[0].columnId, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + }, + ], + }, + previewIcon: 'bullseye', + // dont show suggestions for same type + hide: table.changeType === 'reduced' || (state && state.shape !== 'treemap'), + }; + + results.push(baseSuggestion); + results.push({ + ...baseSuggestion, + title: i18n.translate('xpack.lens.pie.suggestionLabel', { + defaultMessage: 'As {chartName}', + values: { chartName: CHART_NAMES[newShape === 'pie' ? 'donut' : 'pie'].label }, + description: 'chartName is already translated', + }), + score: 0.1, + state: { + ...baseSuggestion.state, + shape: newShape === 'pie' ? 'donut' : 'pie', + }, + hide: true, + }); + } + + if (groups.length <= MAX_TREEMAP_BUCKETS) { + results.push({ + title: i18n.translate('xpack.lens.pie.treemapSuggestionLabel', { + defaultMessage: 'As Treemap', + }), + // Use a higher score when currently active, to prevent chart type switching + // on the user unintentionally + score: state?.shape === 'treemap' ? 0.7 : 0.5, + state: { + shape: 'treemap', + layers: [ + state?.layers[0] + ? { + ...state.layers[0], + layerId: table.layerId, + groups: groups.map(col => col.columnId), + metric: metrics[0].columnId, + } + : { + layerId: table.layerId, + groups: groups.map(col => col.columnId), + metric: metrics[0].columnId, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + }, + ], + }, + previewIcon: 'bullseye', + // hide treemap suggestions from bottom bar, but keep them for chart switcher + hide: table.changeType === 'reduced' || !state || (state && state.shape === 'treemap'), + }); + } + + return [...results].sort((a, b) => a.score - b.score); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts new file mode 100644 index 0000000000000..4a7272b26a63f --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -0,0 +1,54 @@ +/* + * 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 { Ast } from '@kbn/interpreter/common'; +import { FramePublicAPI, Operation } from '../types'; +import { DEFAULT_PERCENT_DECIMALS } from './constants'; +import { PieVisualizationState } from './types'; + +export function toExpression(state: PieVisualizationState, frame: FramePublicAPI) { + return expressionHelper(state, frame, false); +} + +function expressionHelper( + state: PieVisualizationState, + frame: FramePublicAPI, + isPreview: boolean +): Ast | null { + const layer = state.layers[0]; + const datasource = frame.datasourceLayers[layer.layerId]; + const operations = layer.groups + .map(columnId => ({ columnId, operation: datasource.getOperationForColumnId(columnId) })) + .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); + if (!layer.metric || !operations.length) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_pie', + arguments: { + shape: [state.shape], + hideLabels: [isPreview], + groups: operations.map(o => o.columnId), + metric: [layer.metric], + numberDisplay: [layer.numberDisplay], + categoryDisplay: [layer.categoryDisplay], + legendDisplay: [layer.legendDisplay], + percentDecimals: [layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS], + nestedLegend: [!!layer.nestedLegend], + }, + }, + ], + }; +} + +export function toPreviewExpression(state: PieVisualizationState, frame: FramePublicAPI) { + return expressionHelper(state, frame, true); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/types.ts b/x-pack/plugins/lens/public/pie_visualization/types.ts new file mode 100644 index 0000000000000..60b6564248640 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/types.ts @@ -0,0 +1,42 @@ +/* + * 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 { KibanaDatatableColumn } from 'src/plugins/expressions/public'; +import { LensMultiTable } from '../types'; + +export interface SharedLayerState { + groups: string[]; + metric?: string; + numberDisplay: 'hidden' | 'percent' | 'value'; + categoryDisplay: 'default' | 'inside' | 'hide'; + legendDisplay: 'default' | 'show' | 'hide'; + nestedLegend?: boolean; + percentDecimals?: number; +} + +export type LayerState = SharedLayerState & { + layerId: string; +}; + +export interface PieVisualizationState { + shape: 'donut' | 'pie' | 'treemap'; + layers: LayerState[]; +} + +export type PieExpressionArgs = SharedLayerState & { + shape: 'pie' | 'donut' | 'treemap'; + hideLabels: boolean; +}; + +export interface PieExpressionProps { + data: LensMultiTable; + args: PieExpressionArgs; +} + +export type ColumnGroups = Array<{ + col: KibanaDatatableColumn; + metrics: KibanaDatatableColumn[]; +}>; diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.scss b/x-pack/plugins/lens/public/pie_visualization/visualization.scss new file mode 100644 index 0000000000000..d9ff75d849708 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.scss @@ -0,0 +1,4 @@ +.lnsPieExpression__container { + height: 100%; + width: 100%; +} diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index a6acc61922177..a8ec525604977 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -16,6 +16,7 @@ import { IndexPatternDatasource } from './indexpattern_datasource'; import { XyVisualization } from './xy_visualization'; import { MetricVisualization } from './metric_visualization'; import { DatatableVisualization } from './datatable_visualization'; +import { PieVisualization } from './pie_visualization'; import { stopReportManager } from './lens_ui_telemetry'; import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; @@ -48,6 +49,7 @@ export class LensPlugin { private indexpatternDatasource: IndexPatternDatasource; private xyVisualization: XyVisualization; private metricVisualization: MetricVisualization; + private pieVisualization: PieVisualization; constructor() { this.datatableVisualization = new DatatableVisualization(); @@ -55,6 +57,7 @@ export class LensPlugin { this.indexpatternDatasource = new IndexPatternDatasource(); this.xyVisualization = new XyVisualization(); this.metricVisualization = new MetricVisualization(); + this.pieVisualization = new PieVisualization(); } setup( @@ -78,6 +81,7 @@ export class LensPlugin { this.xyVisualization.setup(core, dependencies); this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); + this.pieVisualization.setup(core, dependencies); visualizations.registerAlias(getLensAliasConfig()); @@ -95,6 +99,7 @@ export class LensPlugin { this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; this.xyVisualization.start(core, startDependencies); this.datatableVisualization.start(core, startDependencies); + this.pieVisualization.start(core, startDependencies); } stop() { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx index 92a09f361230c..0f9aa1c10e127 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -1178,5 +1178,135 @@ describe('xy_expression', () => { expect(convertSpy).toHaveBeenCalledWith('I'); }); + + test('it should remove invalid rows', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + ], + rows: [ + { a: undefined, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + second: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + ], + rows: [ + { a: undefined, b: undefined, c: undefined }, + { a: undefined, b: undefined, c: undefined }, + ], + }, + }, + }; + + const args: XYArgs = { + xTitle: '', + yTitle: '', + legend: { type: 'lens_xy_legendConfig', isVisible: false, position: Position.Top }, + layers: [ + { + layerId: 'first', + seriesType: 'line', + xAccessor: 'a', + accessors: ['c'], + splitAccessor: 'b', + columnToLabel: '', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }, + { + layerId: 'second', + seriesType: 'line', + xAccessor: 'a', + accessors: ['c'], + splitAccessor: 'b', + columnToLabel: '', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }, + ], + }; + + const component = shallow( + + ); + + const series = component.find(LineSeries); + + // Only one series should be rendered, even though 2 are configured + // This one series should only have one row, even though 2 are sent + expect(series.prop('data')).toEqual([{ a: 1, b: 5, c: 'J', d: 'Row 2' }]); + }); + + test('it should show legend for split series, even with one row', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + ], + rows: [{ a: 1, b: 5, c: 'J' }], + }, + }, + }; + + const args: XYArgs = { + xTitle: '', + yTitle: '', + legend: { type: 'lens_xy_legendConfig', isVisible: true, position: Position.Top }, + layers: [ + { + layerId: 'first', + seriesType: 'line', + xAccessor: 'a', + accessors: ['c'], + splitAccessor: 'b', + columnToLabel: '', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }, + ], + }; + + const component = shallow( + + ); + + expect(component.find(Settings).prop('showLegend')).toEqual(true); + }); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 81ae57a5ee638..ab0af94cbc2b4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -181,7 +181,17 @@ export function XYChart({ }: XYChartRenderProps) { const { legend, layers } = args; - if (Object.values(data.tables).every(table => table.rows.length === 0)) { + const filteredLayers = layers.filter(({ layerId, xAccessor, accessors }) => { + return !( + !xAccessor || + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + data.tables[layerId].rows.every(row => typeof row[xAccessor] === 'undefined') + ); + }); + + if (filteredLayers.length === 0) { const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; return ( @@ -198,16 +208,16 @@ export function XYChart({ } // use formatting hint of first x axis column to format ticks - const xAxisColumn = Object.values(data.tables)[0].columns.find( - ({ id }) => id === layers[0].xAccessor + const xAxisColumn = data.tables[filteredLayers[0].layerId].columns.find( + ({ id }) => id === filteredLayers[0].xAccessor ); const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.formatHint); // use default number formatter for y axis and use formatting hint if there is just a single y column let yAxisFormatter = formatFactory({ id: 'number' }); - if (layers.length === 1 && layers[0].accessors.length === 1) { + if (filteredLayers.length === 1 && filteredLayers[0].accessors.length === 1) { const firstYAxisColumn = Object.values(data.tables)[0].columns.find( - ({ id }) => id === layers[0].accessors[0] + ({ id }) => id === filteredLayers[0].accessors[0] ); if (firstYAxisColumn && firstYAxisColumn.formatHint) { yAxisFormatter = formatFactory(firstYAxisColumn.formatHint); @@ -215,8 +225,10 @@ export function XYChart({ } const chartHasMoreThanOneSeries = - layers.length > 1 || data.tables[layers[0].layerId].columns.length > 2; - const shouldRotate = isHorizontalChart(layers); + filteredLayers.length > 1 || + filteredLayers.some(layer => layer.accessors.length > 1) || + filteredLayers.some(layer => layer.splitAccessor); + const shouldRotate = isHorizontalChart(filteredLayers); const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; @@ -311,7 +323,7 @@ export function XYChart({ const xySeries = series as XYChartSeriesIdentifier; const xyGeometry = geometry as GeometryValue; - const layer = layers.find(l => + const layer = filteredLayers.find(l => xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) ); if (!layer) { @@ -366,7 +378,7 @@ export function XYChart({ position={shouldRotate ? Position.Left : Position.Bottom} title={xTitle} showGridLines={false} - hide={layers[0].hide} + hide={filteredLayers[0].hide} tickFormat={d => xAxisFormatter.convert(d)} /> @@ -375,11 +387,11 @@ export function XYChart({ position={shouldRotate ? Position.Bottom : Position.Left} title={args.yTitle} showGridLines={false} - hide={layers[0].hide} + hide={filteredLayers[0].hide} tickFormat={d => yAxisFormatter.convert(d)} /> - {layers.map( + {filteredLayers.map( ( { splitAccessor, @@ -394,16 +406,6 @@ export function XYChart({ }, index ) => { - if ( - !xAccessor || - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - data.tables[layerId].rows.every(row => typeof row[xAccessor] === 'undefined') - ) { - return; - } - const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; @@ -414,12 +416,14 @@ export function XYChart({ // To not display them in the legend, they need to be filtered out. const rows = table.rows.filter( row => + xAccessor && + row[xAccessor] && !(splitAccessor && !row[splitAccessor] && accessors.every(accessor => !row[accessor])) ); const seriesProps: SeriesSpec = { splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], - stackAccessors: seriesType.includes('stacked') ? [xAccessor] : [], + stackAccessors: seriesType.includes('stacked') ? [xAccessor as string] : [], id: splitAccessor || accessors.join(','), xAccessor, yAccessors: accessors, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index 73ff88e97f479..722a07f581db5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -11,7 +11,7 @@ import { DataType, TableSuggestion, } from '../types'; -import { State, XYState } from './types'; +import { State, XYState, visualizationTypes } from './types'; import { generateId } from '../id_generator'; jest.mock('../id_generator'); @@ -106,7 +106,68 @@ describe('xy_suggestions', () => { ); }); - test('suggests a basic x y chart with date on x', () => { + test('suggests all basic x y charts when switching from another vis', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: [], + }); + + expect(suggestions).toHaveLength(visualizationTypes.length); + expect(suggestions.map(({ state }) => state.preferredSeriesType)).toEqual([ + 'bar_stacked', + 'area_stacked', + 'area', + 'line', + 'bar_horizontal_stacked', + 'bar_horizontal', + 'bar', + ]); + }); + + test('suggests all basic x y charts when switching from another x y chart', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: ['first'], + state: { + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'first', + seriesType: 'bar', + xAccessor: 'date', + accessors: ['bytes'], + splitAccessor: undefined, + }, + ], + }, + }); + + expect(suggestions).toHaveLength(visualizationTypes.length); + expect(suggestions.map(({ state }) => state.preferredSeriesType)).toEqual([ + 'line', + 'bar', + 'bar_horizontal', + 'bar_stacked', + 'bar_horizontal_stacked', + 'area', + 'area_stacked', + ]); + }); + + test('suggests all basic x y chart with date on x', () => { (generateId as jest.Mock).mockReturnValueOnce('aaa'); const [suggestion, ...rest] = getSuggestions({ table: { @@ -118,7 +179,7 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(visualizationTypes.length - 1); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Array [ Object { @@ -164,7 +225,7 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(visualizationTypes.length - 1); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Array [ Object { @@ -208,8 +269,8 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); - expect(suggestion.title).toEqual('Bar chart'); + expect(rest).toHaveLength(visualizationTypes.length - 1); + expect(suggestion.title).toEqual('Stacked bar'); expect(suggestion.state).toEqual( expect.objectContaining({ layers: [ @@ -267,7 +328,7 @@ describe('xy_suggestions', () => { expect(suggestion.hide).toBeTruthy(); }); - test('only makes a seriesType suggestion for unchanged table without split', () => { + test('makes a visible seriesType suggestion for unchanged table without split', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', @@ -292,8 +353,9 @@ describe('xy_suggestions', () => { keptLayerIds: ['first'], }); - expect(suggestions).toHaveLength(1); + expect(suggestions).toHaveLength(visualizationTypes.length); + expect(suggestions[0].hide).toEqual(false); expect(suggestions[0].state).toEqual({ ...currentState, preferredSeriesType: 'line', @@ -327,7 +389,7 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(visualizationTypes.length - 2); expect(seriesSuggestion.state).toEqual({ ...currentState, preferredSeriesType: 'line', @@ -368,7 +430,7 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(visualizationTypes.length - 1); expect(suggestion.state.preferredSeriesType).toEqual('bar_horizontal'); expect(suggestion.state.layers.every(l => l.seriesType === 'bar_horizontal')).toBeTruthy(); expect(suggestion.title).toEqual('Flip'); @@ -399,14 +461,13 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - const suggestion = suggestions[suggestions.length - 1]; - - expect(suggestion.state).toEqual({ - ...currentState, - preferredSeriesType: 'bar_stacked', - layers: [{ ...currentState.layers[0], seriesType: 'bar_stacked' }], - }); - expect(suggestion.title).toEqual('Stacked'); + const visibleSuggestions = suggestions.filter(suggestion => !suggestion.hide); + expect(visibleSuggestions).toContainEqual( + expect.objectContaining({ + title: 'Stacked', + state: expect.objectContaining({ preferredSeriesType: 'bar_stacked' }), + }) + ); }); test('keeps column to dimension mappings on extended tables', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index abd7640344064..71cb8e0cbdc99 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -14,7 +14,7 @@ import { TableSuggestion, TableChangeType, } from '../types'; -import { State, SeriesType, XYState } from './types'; +import { State, SeriesType, XYState, visualizationTypes } from './types'; import { getIconForSeries } from './state_helpers'; const columnSortOrder = { @@ -180,14 +180,14 @@ function getSuggestionsForLayer({ // handles the simplest cases, acting as a chart switcher if (!currentState && changeType === 'unchanged') { - return [ - { - ...buildSuggestion(options), - title: i18n.translate('xpack.lens.xySuggestions.barChartTitle', { - defaultMessage: 'Bar chart', - }), - }, - ]; + // Chart switcher needs to include every chart type + return visualizationTypes + .map(visType => ({ + ...buildSuggestion({ ...options, seriesType: visType.id as SeriesType }), + title: visType.label, + hide: visType.id !== 'bar_stacked', + })) + .sort((a, b) => (a.state.preferredSeriesType === 'bar_stacked' ? -1 : 1)); } const isSameState = currentState && changeType === 'unchanged'; @@ -248,7 +248,21 @@ function getSuggestionsForLayer({ ); } - return sameStateSuggestions; + // Combine all pre-built suggestions with hidden suggestions for remaining chart types + return sameStateSuggestions.concat( + visualizationTypes + .filter(visType => { + return !sameStateSuggestions.find( + suggestion => suggestion.state.preferredSeriesType === visType.id + ); + }) + .map(visType => { + return { + ...buildSuggestion({ ...options, seriesType: visType.id as SeriesType }), + hide: true, + }; + }) + ); } function toggleStackSeriesType(oldSeriesType: SeriesType) { diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index b1a613e789e85..139f9e88094e0 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -57,6 +57,7 @@ export enum SOURCE_TYPES { ES_GEO_GRID = 'ES_GEO_GRID', ES_SEARCH = 'ES_SEARCH', ES_PEW_PEW = 'ES_PEW_PEW', + ES_TERM_SOURCE = 'ES_TERM_SOURCE', EMS_XYZ = 'EMS_XYZ', // identifies a custom TMS source. Name is a little unfortunate. WMS = 'WMS', KIBANA_TILEMAP = 'KIBANA_TILEMAP', @@ -174,8 +175,6 @@ export const COLOR_MAP_TYPE = { ORDINAL: 'ORDINAL', }; -export const COLOR_PALETTE_MAX_SIZE = 10; - export const CATEGORICAL_DATA_TYPES = ['string', 'ip', 'boolean']; export const ORDINAL_DATA_TYPES = ['number', 'date']; diff --git a/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts b/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts index 6980f14d0788a..a9a9fa17c41fc 100644 --- a/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts +++ b/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts @@ -71,6 +71,7 @@ export type ESPewPewSourceDescriptor = AbstractESAggSourceDescriptor & { export type ESTermSourceDescriptor = AbstractESAggSourceDescriptor & { indexPatternTitle: string; term: string; // term field name + whereQuery?: Query; }; export type KibanaRegionmapSourceDescriptor = AbstractSourceDescriptor & { diff --git a/x-pack/plugins/maps/common/get_join_key.ts b/x-pack/plugins/maps/common/get_agg_key.ts similarity index 62% rename from x-pack/plugins/maps/common/get_join_key.ts rename to x-pack/plugins/maps/common/get_agg_key.ts index f1ee95126b9a9..19c39b4dfa6bd 100644 --- a/x-pack/plugins/maps/common/get_join_key.ts +++ b/x-pack/plugins/maps/common/get_agg_key.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AGG_DELIMITER, AGG_TYPE, JOIN_FIELD_NAME_PREFIX } from './constants'; +import { AGG_DELIMITER, AGG_TYPE, COUNT_PROP_NAME, JOIN_FIELD_NAME_PREFIX } from './constants'; -// function in common since its needed by migration export function getJoinAggKey({ aggType, aggFieldName, @@ -15,8 +14,18 @@ export function getJoinAggKey({ aggType: AGG_TYPE; aggFieldName?: string; rightSourceId: string; -}) { +}): string { const metricKey = aggType !== AGG_TYPE.COUNT ? `${aggType}${AGG_DELIMITER}${aggFieldName}` : aggType; return `${JOIN_FIELD_NAME_PREFIX}${metricKey}__${rightSourceId}`; } + +export function getSourceAggKey({ + aggType, + aggFieldName, +}: { + aggType: AGG_TYPE; + aggFieldName?: string; +}): string { + return aggType !== AGG_TYPE.COUNT ? `${aggType}${AGG_DELIMITER}${aggFieldName}` : COUNT_PROP_NAME; +} diff --git a/x-pack/plugins/maps/common/migrations/join_agg_key.ts b/x-pack/plugins/maps/common/migrations/join_agg_key.ts index 97b9ee4692c25..92e3172f218b9 100644 --- a/x-pack/plugins/maps/common/migrations/join_agg_key.ts +++ b/x-pack/plugins/maps/common/migrations/join_agg_key.ts @@ -13,7 +13,7 @@ import { LAYER_TYPE, VECTOR_STYLES, } from '../constants'; -import { getJoinAggKey } from '../get_join_key'; +import { getJoinAggKey } from '../get_agg_key'; import { AggDescriptor, JoinDescriptor, diff --git a/x-pack/plugins/maps/public/actions/map_actions.d.ts b/x-pack/plugins/maps/public/actions/map_actions.d.ts index 413b440279d77..3bda29964a9a1 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.d.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.d.ts @@ -13,6 +13,7 @@ import { MapFilters, MapCenterAndZoom, MapRefreshConfig, + MapExtent, } from '../../common/descriptor_types'; import { MapSettings } from '../reducers/map'; @@ -34,6 +35,9 @@ export function updateSourceProp( ): void; export function setGotoWithCenter(config: MapCenterAndZoom): AnyAction; +export function setGotoWithBounds(config: MapExtent): AnyAction; + +export function fitToDataBounds(): AnyAction; export function replaceLayerList(layerList: unknown[]): AnyAction; diff --git a/x-pack/plugins/maps/public/actions/map_actions.js b/x-pack/plugins/maps/public/actions/map_actions.js index da6ba6b481054..ea2602397702b 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.js +++ b/x-pack/plugins/maps/public/actions/map_actions.js @@ -19,6 +19,7 @@ import { getOpenTooltips, getQuery, getDataRequestDescriptor, + getFittableLayers, } from '../selectors/map_selectors'; import { FLYOUT_STATE } from '../reducers/ui'; @@ -567,6 +568,55 @@ export function fitToLayerExtent(layerId) { }; } +export function fitToDataBounds() { + return async function(dispatch, getState) { + const layerList = getFittableLayers(getState()); + + if (!layerList.length) { + return; + } + + const dataFilters = getDataFilters(getState()); + const boundsPromises = layerList.map(async layer => { + return layer.getBounds(dataFilters); + }); + + const bounds = await Promise.all(boundsPromises); + const corners = []; + for (let i = 0; i < bounds.length; i++) { + const b = bounds[i]; + + //filter out undefined bounds (uses Infinity due to turf responses) + + if ( + b.minLon === Infinity || + b.maxLon === Infinity || + b.minLat === -Infinity || + b.maxLat === -Infinity + ) { + continue; + } + + corners.push([b.minLon, b.minLat]); + corners.push([b.maxLon, b.maxLat]); + } + + if (!corners.length) { + return; + } + + const turfUnionBbox = turf.bbox(turf.multiPoint(corners)); + const dataBounds = { + minLon: turfUnionBbox[0], + minLat: turfUnionBbox[1], + maxLon: turfUnionBbox[2], + maxLat: turfUnionBbox[3], + }; + + dispatch(setGotoWithBounds(dataBounds)); + }; +} + export function setGotoWithBounds(bounds) { return { type: SET_GOTO, diff --git a/x-pack/plugins/maps/public/angular/listing_ng_wrapper.html b/x-pack/plugins/maps/public/angular/listing_ng_wrapper.html index ed787edec6e01..bfea81e13e9df 100644 --- a/x-pack/plugins/maps/public/angular/listing_ng_wrapper.html +++ b/x-pack/plugins/maps/public/angular/listing_ng_wrapper.html @@ -3,4 +3,4 @@ delete="delete" listing-limit="listingLimit" read-only="readOnly" -/> +> diff --git a/x-pack/plugins/maps/public/connected_components/_index.scss b/x-pack/plugins/maps/public/connected_components/_index.scss index 83042ae1d586c..a961e652046a6 100644 --- a/x-pack/plugins/maps/public/connected_components/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/_index.scss @@ -1,5 +1,5 @@ @import 'gis_map/gis_map'; -@import 'layer_addpanel/source_select/index'; +@import 'layer_addpanel/index'; @import 'layer_panel/index'; @import 'widget_overlay/index'; @import 'toolbar_overlay/index'; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_source_select.scss b/x-pack/plugins/maps/public/connected_components/layer_addpanel/_index.scss similarity index 100% rename from x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_source_select.scss rename to x-pack/plugins/maps/public/connected_components/layer_addpanel/_index.scss diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/layer_wizard_select.tsx b/x-pack/plugins/maps/public/connected_components/layer_addpanel/layer_wizard_select.tsx new file mode 100644 index 0000000000000..0359ed2c6269d --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/layer_wizard_select.tsx @@ -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 _ from 'lodash'; +import React, { Component, Fragment } from 'react'; +import { EuiSpacer, EuiCard, EuiIcon } from '@elastic/eui'; +import { getLayerWizards, LayerWizard } from '../../layers/layer_wizard_registry'; + +interface Props { + onSelect: (layerWizard: LayerWizard) => void; +} + +interface State { + layerWizards: LayerWizard[]; +} + +export class LayerWizardSelect extends Component { + private _isMounted: boolean = false; + + state = { + layerWizards: [], + }; + + componentDidMount() { + this._isMounted = true; + this._loadLayerWizards(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadLayerWizards() { + const layerWizards = await getLayerWizards(); + if (this._isMounted) { + this.setState({ layerWizards }); + } + } + + render() { + return this.state.layerWizards.map((layerWizard: LayerWizard) => { + const icon = layerWizard.icon ? : undefined; + + const onClick = () => { + this.props.onSelect(layerWizard); + }; + + return ( + + + + + ); + }); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_index.scss b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_index.scss deleted file mode 100644 index 8ae6970315e13..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'source_select'; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js deleted file mode 100644 index 82df9237e6ed3..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js +++ /dev/null @@ -1,41 +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, { Fragment } from 'react'; - -import { getLayerWizards } from '../../../layers/layer_wizard_registry'; -import { EuiSpacer, EuiCard, EuiIcon } from '@elastic/eui'; -import _ from 'lodash'; - -export function SourceSelect({ updateSourceSelection }) { - const sourceCards = getLayerWizards().map(layerWizard => { - const icon = layerWizard.icon ? : null; - - const onClick = () => { - updateSourceSelection({ - layerWizard: layerWizard, - isIndexingSource: !!layerWizard.isIndexingSource, - }); - }; - - return ( - - - - - ); - }); - - return {sourceCards}; -} diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js index 127b99d730db5..730e58a107aad 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js @@ -5,7 +5,7 @@ */ import React, { Component, Fragment } from 'react'; -import { SourceSelect } from './source_select/source_select'; +import { LayerWizardSelect } from './layer_wizard_select'; import { FlyoutFooter } from './flyout_footer'; import { ImportEditor } from './import_editor'; import { EuiButtonEmpty, EuiPanel, EuiTitle, EuiFlyoutHeader, EuiSpacer } from '@elastic/eui'; @@ -80,8 +80,8 @@ export class AddLayerPanel extends Component { this.props.removeTransientLayer(); }; - _onSourceSelectionChange = ({ layerWizard, isIndexingSource }) => { - this.setState({ layerWizard, importView: isIndexingSource }); + _onWizardSelect = layerWizard => { + this.setState({ layerWizard, importView: !!layerWizard.isIndexingSource }); }; _layerAddHandler = () => { @@ -100,7 +100,7 @@ export class AddLayerPanel extends Component { _renderPanelBody() { if (!this.state.layerWizard) { - return ; + return ; } const backButton = this.props.isIndexingTriggered ? null : ( diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap new file mode 100644 index 0000000000000..3407bcfd4f845 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Must render zoom tools 1`] = ` + + + + + + + + +`; + +exports[`Must zoom tools and draw filter tools 1`] = ` + + + + + + + + + + + +`; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss index 2754a3e204263..e92e89b170370 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss @@ -12,6 +12,7 @@ // sass-lint:disable-block no-important background-color: $euiColorEmptyShade !important; pointer-events: all; + position: relative; &:enabled, &:enabled:hover, diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx new file mode 100644 index 0000000000000..0b168badb2f3f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx @@ -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. + */ + +import React from 'react'; + +import { EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ILayer } from '../../../layers/layer'; + +interface Props { + layerList: ILayer[]; + fitToBounds: () => void; +} + +export const FitToData: React.FunctionComponent = ({ layerList, fitToBounds }: Props) => { + if (layerList.length === 0) { + return null; + } + + return ( + + ); +}; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts new file mode 100644 index 0000000000000..d6ded62f2f480 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AnyAction, Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { MapStoreState } from '../../../reducers/store'; +import { fitToDataBounds } from '../../../actions/map_actions'; +import { getFittableLayers } from '../../../selectors/map_selectors'; +import { FitToData } from './fit_to_data'; + +function mapStateToProps(state: MapStoreState) { + return { + layerList: getFittableLayers(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + fitToBounds: () => { + dispatch(fitToDataBounds()); + }, + }; +} + +const connectedFitToData = connect(mapStateToProps, mapDispatchToProps)(FitToData); +export { connectedFitToData as FitToData }; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js index 32668be8f8f67..a4f85163512f7 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js @@ -8,6 +8,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { SetViewControl } from './set_view_control'; import { ToolsControl } from './tools_control'; +import { FitToData } from './fit_to_data'; export class ToolbarOverlay extends React.Component { _renderToolsControl() { @@ -36,6 +37,10 @@ export class ToolbarOverlay extends React.Component {
+ + + + {this._renderToolsControl()}
); diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx new file mode 100644 index 0000000000000..c03aa1e098540 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.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 { shallow } from 'enzyme'; + +// @ts-ignore +import { ToolbarOverlay } from './toolbar_overlay'; + +test('Must render zoom tools', async () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('Must zoom tools and draw filter tools', async () => { + const component = shallow( {}} geoFields={['coordinates']} />); + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap index b8c652909408a..388712e1ebcca 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap @@ -72,7 +72,7 @@ exports[`TOCEntryActionsPopover is rendered 1`] = ` "disabled": false, "icon": , "name": "Fit to data", "onClick": [Function], @@ -200,7 +200,7 @@ exports[`TOCEntryActionsPopover should disable fit to data when supportsFitToBou "disabled": true, "icon": , "name": "Fit to data", "onClick": [Function], @@ -328,7 +328,7 @@ exports[`TOCEntryActionsPopover should not show edit actions in read only mode 1 "disabled": false, "icon": , "name": "Fit to data", "onClick": [Function], diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx index e0fdff62829fb..dfc93c29263ee 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx @@ -137,7 +137,7 @@ export class TOCEntryActionsPopover extends Component { name: i18n.translate('xpack.maps.layerTocActions.fitToDataTitle', { defaultMessage: 'Fit to data', }), - icon: , + icon: , 'data-test-subj': 'fitToBoundsButton', toolTipContent: this.state.supportsFitToBounds ? null diff --git a/x-pack/plugins/maps/public/index_pattern_util.js b/x-pack/plugins/maps/public/index_pattern_util.js index 6cb02c7605e28..bbea4a9e3ab2a 100644 --- a/x-pack/plugins/maps/public/index_pattern_util.js +++ b/x-pack/plugins/maps/public/index_pattern_util.js @@ -32,14 +32,18 @@ export function getTermsFields(fields) { export const AGGREGATABLE_GEO_FIELD_TYPES = [ES_GEO_FIELD_TYPE.GEO_POINT]; -export function getAggregatableGeoFields(fields) { - return fields.filter(field => { - return ( - field.aggregatable && - !indexPatterns.isNestedField(field) && - AGGREGATABLE_GEO_FIELD_TYPES.includes(field.type) - ); - }); +export function getFieldsWithGeoTileAgg(fields) { + return fields.filter(supportsGeoTileAgg); +} + +export function supportsGeoTileAgg(field) { + // TODO add geo_shape support with license check + return ( + field && + field.aggregatable && + !indexPatterns.isNestedField(field) && + field.type === ES_GEO_FIELD_TYPE.GEO_POINT + ); } // Returns filtered fields list containing only fields that exist in _source. diff --git a/x-pack/plugins/maps/public/layers/blended_vector_layer.ts b/x-pack/plugins/maps/public/layers/blended_vector_layer.ts index 5c486200977d7..adf04b4155659 100644 --- a/x-pack/plugins/maps/public/layers/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/layers/blended_vector_layer.ts @@ -260,33 +260,40 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { prevDataRequest: this.getDataRequest(dataRequestId), nextMeta: searchFilters, }); - if (canSkipFetch) { - return; - } - - let isSyncClustered; - try { - syncContext.startLoading(dataRequestId, requestToken, searchFilters); - const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); - const resp = await searchSource.fetch(); - const maxResultWindow = await this._documentSource.getMaxResultWindow(); - isSyncClustered = resp.hits.total > maxResultWindow; - syncContext.stopLoading(dataRequestId, requestToken, { isSyncClustered }, searchFilters); - } catch (error) { - if (!(error instanceof DataRequestAbortError)) { - syncContext.onLoadError(dataRequestId, requestToken, error.message); - } - return; - } let activeSource; let activeStyle; - if (isSyncClustered) { - activeSource = this._clusterSource; - activeStyle = this._clusterStyle; + if (canSkipFetch) { + // Even when source fetch is skipped, need to call super._syncData to sync StyleMeta and formatters + if (this._isClustered) { + activeSource = this._clusterSource; + activeStyle = this._clusterStyle; + } else { + activeSource = this._documentSource; + activeStyle = this._documentStyle; + } } else { - activeSource = this._documentSource; - activeStyle = this._documentStyle; + let isSyncClustered; + try { + syncContext.startLoading(dataRequestId, requestToken, searchFilters); + const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); + const resp = await searchSource.fetch(); + const maxResultWindow = await this._documentSource.getMaxResultWindow(); + isSyncClustered = resp.hits.total > maxResultWindow; + syncContext.stopLoading(dataRequestId, requestToken, { isSyncClustered }, searchFilters); + } catch (error) { + if (!(error instanceof DataRequestAbortError)) { + syncContext.onLoadError(dataRequestId, requestToken, error.message); + } + return; + } + if (isSyncClustered) { + activeSource = this._clusterSource; + activeStyle = this._clusterStyle; + } else { + activeSource = this._documentSource; + activeStyle = this._documentStyle; + } } super._syncData(syncContext, activeSource, activeStyle); diff --git a/x-pack/plugins/maps/public/layers/fields/es_agg_field.ts b/x-pack/plugins/maps/public/layers/fields/es_agg_field.ts index 34f7dd4b9578f..4a3ac6390c5a7 100644 --- a/x-pack/plugins/maps/public/layers/fields/es_agg_field.ts +++ b/x-pack/plugins/maps/public/layers/fields/es_agg_field.ts @@ -125,8 +125,8 @@ export class ESAggField implements IESAggField { return this._esDocField ? this._esDocField.getOrdinalFieldMetaRequest() : null; } - async getCategoricalFieldMetaRequest(): Promise { - return this._esDocField ? this._esDocField.getCategoricalFieldMetaRequest() : null; + async getCategoricalFieldMetaRequest(size: number): Promise { + return this._esDocField ? this._esDocField.getCategoricalFieldMetaRequest(size) : null; } } diff --git a/x-pack/plugins/maps/public/layers/fields/es_doc_field.ts b/x-pack/plugins/maps/public/layers/fields/es_doc_field.ts index b7647d881fcf6..670b3ba32888b 100644 --- a/x-pack/plugins/maps/public/layers/fields/es_doc_field.ts +++ b/x-pack/plugins/maps/public/layers/fields/es_doc_field.ts @@ -7,7 +7,6 @@ import { FIELD_ORIGIN } from '../../../common/constants'; import { ESTooltipProperty } from '../tooltips/es_tooltip_property'; import { ITooltipProperty, TooltipProperty } from '../tooltips/tooltip_property'; -import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants'; import { indexPatterns } from '../../../../../../src/plugins/data/public'; import { IFieldType } from '../../../../../../src/plugins/data/public'; import { IField, AbstractField } from './field'; @@ -89,16 +88,16 @@ export class ESDocField extends AbstractField implements IField { }; } - async getCategoricalFieldMetaRequest(): Promise { + async getCategoricalFieldMetaRequest(size: number): Promise { const indexPatternField = await this._getIndexPatternField(); - if (!indexPatternField) { + if (!indexPatternField || size <= 0) { return null; } // TODO remove local typing once Kibana has figured out a core place for Elasticsearch aggregation request types // https://github.com/elastic/kibana/issues/60102 const topTerms: { size: number; script?: unknown; field?: string } = { - size: COLOR_PALETTE_MAX_SIZE - 1, // need additional color for the "other"-value + size: size - 1, // need additional color for the "other"-value }; if (indexPatternField.scripted) { topTerms.script = { diff --git a/x-pack/plugins/maps/public/layers/fields/field.ts b/x-pack/plugins/maps/public/layers/fields/field.ts index b431be4aa6cb8..539d0ab4d6ade 100644 --- a/x-pack/plugins/maps/public/layers/fields/field.ts +++ b/x-pack/plugins/maps/public/layers/fields/field.ts @@ -19,7 +19,7 @@ export interface IField { getOrigin(): FIELD_ORIGIN; isValid(): boolean; getOrdinalFieldMetaRequest(): Promise; - getCategoricalFieldMetaRequest(): Promise; + getCategoricalFieldMetaRequest(size: number): Promise; } export class AbstractField implements IField { @@ -76,7 +76,7 @@ export class AbstractField implements IField { return null; } - async getCategoricalFieldMetaRequest(): Promise { + async getCategoricalFieldMetaRequest(size: number): Promise { return null; } } diff --git a/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts index 7715541b1c52d..f866fc10b1f47 100644 --- a/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts @@ -20,7 +20,7 @@ export type RenderWizardArguments = { }; export type LayerWizard = { - checkVisibility?: () => boolean; + checkVisibility?: () => Promise; description: string; icon: string; isIndexingSource?: boolean; @@ -31,11 +31,23 @@ export type LayerWizard = { const registry: LayerWizard[] = []; export function registerLayerWizard(layerWizard: LayerWizard) { - registry.push(layerWizard); + registry.push({ + checkVisibility: async () => { + return true; + }, + ...layerWizard, + }); } -export function getLayerWizards(): LayerWizard[] { - return registry.filter(layerWizard => { - return layerWizard.checkVisibility ? layerWizard.checkVisibility() : true; +export async function getLayerWizards(): Promise { + const promises = registry.map(async layerWizard => { + return { + ...layerWizard, + // @ts-ignore + isVisible: await layerWizard.checkVisibility(), + }; + }); + return (await Promise.all(promises)).filter(({ isVisible }) => { + return isVisible; }); } diff --git a/x-pack/plugins/maps/public/layers/load_layer_wizards.ts b/x-pack/plugins/maps/public/layers/load_layer_wizards.ts index 0b83f3bbdc613..098ff51791d79 100644 --- a/x-pack/plugins/maps/public/layers/load_layer_wizards.ts +++ b/x-pack/plugins/maps/public/layers/load_layer_wizards.ts @@ -25,17 +25,19 @@ import { tmsLayerWizardConfig } from './sources/xyz_tms_source'; // @ts-ignore import { wmsLayerWizardConfig } from './sources/wms_source'; import { mvtVectorSourceWizardConfig } from './sources/mvt_single_layer_vector_source'; -// @ts-ignore +import { ObservabilityLayerWizardConfig } from './solution_layers/observability'; import { getInjectedVarFunc } from '../kibana_services'; -// Registration order determines display order let registered = false; export function registerLayerWizards() { if (registered) { return; } + + // Registration order determines display order // @ts-ignore registerLayerWizard(uploadLayerWizardConfig); + registerLayerWizard(ObservabilityLayerWizardConfig); // @ts-ignore registerLayerWizard(esDocumentsLayerWizardConfig); // @ts-ignore diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.test.ts b/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.test.ts new file mode 100644 index 0000000000000..6c0b6cdc39b85 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.test.ts @@ -0,0 +1,336 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('../../../kibana_services', () => { + const mockUiSettings = { + get: () => { + return undefined; + }, + }; + return { + getUiSettings: () => { + return mockUiSettings; + }, + }; +}); + +jest.mock('uuid/v4', () => { + return function() { + return '12345'; + }; +}); + +import { createLayerDescriptor } from './create_layer_descriptor'; +import { OBSERVABILITY_LAYER_TYPE } from './layer_select'; +import { OBSERVABILITY_METRIC_TYPE } from './metric_select'; +import { DISPLAY } from './display_select'; + +describe('createLayerDescriptor', () => { + test('Should create vector layer descriptor with join when displayed as choropleth', () => { + const layerDescriptor = createLayerDescriptor({ + layer: OBSERVABILITY_LAYER_TYPE.APM_RUM_PERFORMANCE, + metric: OBSERVABILITY_METRIC_TYPE.TRANSACTION_DURATION, + display: DISPLAY.CHOROPLETH, + }); + + expect(layerDescriptor).toEqual({ + __dataRequests: [], + alpha: 0.75, + id: '12345', + joins: [ + { + leftField: 'iso2', + right: { + id: '12345', + indexPatternId: 'apm_static_index_pattern_id', + indexPatternTitle: 'apm-*', + metrics: [ + { + field: 'transaction.duration.us', + type: 'avg', + }, + ], + term: 'client.geo.country_iso_code', + type: 'ES_TERM_SOURCE', + whereQuery: { + language: 'kuery', + query: 'processor.event:"transaction"', + }, + }, + }, + ], + label: '[Performance] Duration', + maxZoom: 24, + minZoom: 0, + sourceDescriptor: { + id: 'world_countries', + tooltipProperties: ['name', 'iso2'], + type: 'EMS_FILE', + }, + style: { + isTimeAware: true, + properties: { + fillColor: { + options: { + color: 'Green to Red', + colorCategory: 'palette_0', + field: { + name: '__kbnjoin__avg_of_transaction.duration.us__12345', + origin: 'join', + }, + fieldMetaOptions: { + isEnabled: true, + sigma: 3, + }, + type: 'ORDINAL', + }, + type: 'DYNAMIC', + }, + icon: { + options: { + value: 'marker', + }, + type: 'STATIC', + }, + iconOrientation: { + options: { + orientation: 0, + }, + type: 'STATIC', + }, + iconSize: { + options: { + size: 6, + }, + type: 'STATIC', + }, + labelText: { + options: { + value: '', + }, + type: 'STATIC', + }, + labelBorderColor: { + options: { + color: '#FFFFFF', + }, + type: 'STATIC', + }, + labelBorderSize: { + options: { + size: 'SMALL', + }, + }, + labelColor: { + options: { + color: '#000000', + }, + type: 'STATIC', + }, + labelSize: { + options: { + size: 14, + }, + type: 'STATIC', + }, + lineColor: { + options: { + color: '#3d3d3d', + }, + type: 'STATIC', + }, + lineWidth: { + options: { + size: 1, + }, + type: 'STATIC', + }, + symbolizeAs: { + options: { + value: 'circle', + }, + }, + }, + type: 'VECTOR', + }, + type: 'VECTOR', + visible: true, + }); + }); + + test('Should create heatmap layer descriptor when displayed as heatmap', () => { + const layerDescriptor = createLayerDescriptor({ + layer: OBSERVABILITY_LAYER_TYPE.APM_RUM_PERFORMANCE, + metric: OBSERVABILITY_METRIC_TYPE.TRANSACTION_DURATION, + display: DISPLAY.HEATMAP, + }); + expect(layerDescriptor).toEqual({ + __dataRequests: [], + alpha: 0.75, + id: '12345', + joins: [], + label: '[Performance] Duration', + maxZoom: 24, + minZoom: 0, + query: { + language: 'kuery', + query: 'processor.event:"transaction"', + }, + sourceDescriptor: { + geoField: 'client.geo.location', + id: '12345', + indexPatternId: 'apm_static_index_pattern_id', + metrics: [ + { + field: 'transaction.duration.us', + type: 'avg', + }, + ], + requestType: 'heatmap', + resolution: 'MOST_FINE', + type: 'ES_GEO_GRID', + }, + style: { + colorRampName: 'theclassic', + type: 'HEATMAP', + }, + type: 'HEATMAP', + visible: true, + }); + }); + + test('Should create vector layer descriptor when displayed as clusters', () => { + const layerDescriptor = createLayerDescriptor({ + layer: OBSERVABILITY_LAYER_TYPE.APM_RUM_PERFORMANCE, + metric: OBSERVABILITY_METRIC_TYPE.TRANSACTION_DURATION, + display: DISPLAY.CLUSTERS, + }); + expect(layerDescriptor).toEqual({ + __dataRequests: [], + alpha: 0.75, + id: '12345', + joins: [], + label: '[Performance] Duration', + maxZoom: 24, + minZoom: 0, + query: { + language: 'kuery', + query: 'processor.event:"transaction"', + }, + sourceDescriptor: { + geoField: 'client.geo.location', + id: '12345', + indexPatternId: 'apm_static_index_pattern_id', + metrics: [ + { + field: 'transaction.duration.us', + type: 'avg', + }, + ], + requestType: 'point', + resolution: 'MOST_FINE', + type: 'ES_GEO_GRID', + }, + style: { + isTimeAware: true, + properties: { + fillColor: { + options: { + color: 'Green to Red', + colorCategory: 'palette_0', + field: { + name: 'avg_of_transaction.duration.us', + origin: 'source', + }, + fieldMetaOptions: { + isEnabled: true, + sigma: 3, + }, + type: 'ORDINAL', + }, + type: 'DYNAMIC', + }, + icon: { + options: { + value: 'marker', + }, + type: 'STATIC', + }, + iconOrientation: { + options: { + orientation: 0, + }, + type: 'STATIC', + }, + iconSize: { + options: { + field: { + name: 'avg_of_transaction.duration.us', + origin: 'source', + }, + fieldMetaOptions: { + isEnabled: true, + sigma: 3, + }, + maxSize: 32, + minSize: 7, + }, + type: 'DYNAMIC', + }, + labelText: { + options: { + value: '', + }, + type: 'STATIC', + }, + labelBorderColor: { + options: { + color: '#FFFFFF', + }, + type: 'STATIC', + }, + labelBorderSize: { + options: { + size: 'SMALL', + }, + }, + labelColor: { + options: { + color: '#000000', + }, + type: 'STATIC', + }, + labelSize: { + options: { + size: 14, + }, + type: 'STATIC', + }, + lineColor: { + options: { + color: '#3d3d3d', + }, + type: 'STATIC', + }, + lineWidth: { + options: { + size: 1, + }, + type: 'STATIC', + }, + symbolizeAs: { + options: { + value: 'circle', + }, + }, + }, + type: 'VECTOR', + }, + type: 'VECTOR', + visible: true, + }); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.ts b/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.ts new file mode 100644 index 0000000000000..a59122d7d6309 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.ts @@ -0,0 +1,270 @@ +/* + * 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 uuid from 'uuid/v4'; +import { i18n } from '@kbn/i18n'; +import { + AggDescriptor, + ColorDynamicOptions, + LabelDynamicOptions, + LayerDescriptor, + SizeDynamicOptions, + StylePropertyField, + VectorStylePropertiesDescriptor, +} from '../../../../common/descriptor_types'; +import { + AGG_TYPE, + COLOR_MAP_TYPE, + FIELD_ORIGIN, + GRID_RESOLUTION, + RENDER_AS, + SOURCE_TYPES, + STYLE_TYPE, + VECTOR_STYLES, +} from '../../../../common/constants'; +import { getJoinAggKey, getSourceAggKey } from '../../../../common/get_agg_key'; +import { OBSERVABILITY_LAYER_TYPE } from './layer_select'; +import { OBSERVABILITY_METRIC_TYPE } from './metric_select'; +import { DISPLAY } from './display_select'; +import { VectorStyle } from '../../styles/vector/vector_style'; +// @ts-ignore +import { EMSFileSource } from '../../sources/ems_file_source'; +// @ts-ignore +import { ESGeoGridSource } from '../../sources/es_geo_grid_source'; +import { VectorLayer } from '../../vector_layer'; +// @ts-ignore +import { HeatmapLayer } from '../../heatmap_layer'; +import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_defaults'; + +// redefining APM constant to avoid making maps app depend on APM plugin +export const APM_INDEX_PATTERN_ID = 'apm_static_index_pattern_id'; + +const defaultDynamicProperties = getDefaultDynamicProperties(); + +function createDynamicFillColorDescriptor( + layer: OBSERVABILITY_LAYER_TYPE, + field: StylePropertyField +) { + return { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR]!.options as ColorDynamicOptions), + field, + color: + layer === OBSERVABILITY_LAYER_TYPE.APM_RUM_PERFORMANCE ? 'Green to Red' : 'Yellow to Red', + type: COLOR_MAP_TYPE.ORDINAL, + }, + }; +} + +function createLayerLabel( + layer: OBSERVABILITY_LAYER_TYPE, + metric: OBSERVABILITY_METRIC_TYPE +): string | null { + let layerName; + if (layer === OBSERVABILITY_LAYER_TYPE.APM_RUM_PERFORMANCE) { + layerName = i18n.translate('xpack.maps.observability.apmRumPerformanceLayerName', { + defaultMessage: 'Performance', + }); + } else if (layer === OBSERVABILITY_LAYER_TYPE.APM_RUM_TRAFFIC) { + layerName = i18n.translate('xpack.maps.observability.apmRumTrafficLayerName', { + defaultMessage: 'Traffic', + }); + } + + let metricName; + if (metric === OBSERVABILITY_METRIC_TYPE.TRANSACTION_DURATION) { + metricName = i18n.translate('xpack.maps.observability.durationMetricName', { + defaultMessage: 'Duration', + }); + } else if (metric === OBSERVABILITY_METRIC_TYPE.SLA_PERCENTAGE) { + metricName = i18n.translate('xpack.maps.observability.slaPercentageMetricName', { + defaultMessage: '% Duration of SLA', + }); + } else if (metric === OBSERVABILITY_METRIC_TYPE.COUNT) { + metricName = i18n.translate('xpack.maps.observability.countMetricName', { + defaultMessage: 'Total', + }); + } else if (metric === OBSERVABILITY_METRIC_TYPE.UNIQUE_COUNT) { + metricName = i18n.translate('xpack.maps.observability.uniqueCountMetricName', { + defaultMessage: 'Unique count', + }); + } + + return `[${layerName}] ${metricName}`; +} + +function createAggDescriptor(metric: OBSERVABILITY_METRIC_TYPE): AggDescriptor { + if (metric === OBSERVABILITY_METRIC_TYPE.TRANSACTION_DURATION) { + return { + type: AGG_TYPE.AVG, + field: 'transaction.duration.us', + }; + } else if (metric === OBSERVABILITY_METRIC_TYPE.SLA_PERCENTAGE) { + return { + type: AGG_TYPE.AVG, + field: 'duration_sla_pct', + }; + } else if (metric === OBSERVABILITY_METRIC_TYPE.UNIQUE_COUNT) { + return { + type: AGG_TYPE.UNIQUE_COUNT, + field: 'transaction.id', + }; + } else { + return { type: AGG_TYPE.COUNT }; + } +} + +// All APM indices match APM index pattern. Need to apply query to filter to specific document types +// https://www.elastic.co/guide/en/kibana/current/apm-settings-kb.html +function createAmpSourceQuery(layer: OBSERVABILITY_LAYER_TYPE) { + // APM transaction documents + let query; + if ( + layer === OBSERVABILITY_LAYER_TYPE.APM_RUM_PERFORMANCE || + layer === OBSERVABILITY_LAYER_TYPE.APM_RUM_TRAFFIC + ) { + query = 'processor.event:"transaction"'; + } + + return query + ? { + language: 'kuery', + query, + } + : undefined; +} + +function getGeoGridRequestType(display: DISPLAY): RENDER_AS { + if (display === DISPLAY.HEATMAP) { + return RENDER_AS.HEATMAP; + } + + if (display === DISPLAY.GRIDS) { + return RENDER_AS.GRID; + } + + return RENDER_AS.POINT; +} + +export function createLayerDescriptor({ + layer, + metric, + display, +}: { + layer: OBSERVABILITY_LAYER_TYPE | null; + metric: OBSERVABILITY_METRIC_TYPE | null; + display: DISPLAY | null; +}): LayerDescriptor | null { + if (!layer || !metric || !display) { + return null; + } + + const apmSourceQuery = createAmpSourceQuery(layer); + const label = createLayerLabel(layer, metric); + const metricsDescriptor = createAggDescriptor(metric); + + if (display === DISPLAY.CHOROPLETH) { + const joinId = uuid(); + const joinKey = getJoinAggKey({ + aggType: metricsDescriptor.type, + aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + rightSourceId: joinId, + }); + return VectorLayer.createDescriptor({ + label, + joins: [ + { + leftField: 'iso2', + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + id: joinId, + indexPatternId: APM_INDEX_PATTERN_ID, + indexPatternTitle: 'apm-*', // TODO look up from APM_OSS.indexPattern + term: 'client.geo.country_iso_code', + metrics: [metricsDescriptor], + whereQuery: apmSourceQuery, + }, + }, + ], + sourceDescriptor: EMSFileSource.createDescriptor({ + id: 'world_countries', + tooltipProperties: ['name', 'iso2'], + }), + style: VectorStyle.createDescriptor({ + [VECTOR_STYLES.FILL_COLOR]: createDynamicFillColorDescriptor(layer, { + name: joinKey, + origin: FIELD_ORIGIN.JOIN, + }), + [VECTOR_STYLES.LINE_COLOR]: { + type: STYLE_TYPE.STATIC, + options: { + color: '#3d3d3d', + }, + }, + }), + }); + } + + const geoGridSourceDescriptor = ESGeoGridSource.createDescriptor({ + indexPatternId: APM_INDEX_PATTERN_ID, + geoField: 'client.geo.location', + metrics: [metricsDescriptor], + requestType: getGeoGridRequestType(display), + resolution: GRID_RESOLUTION.MOST_FINE, + }); + + if (display === DISPLAY.HEATMAP) { + return HeatmapLayer.createDescriptor({ + label, + query: apmSourceQuery, + sourceDescriptor: geoGridSourceDescriptor, + }); + } + + const metricSourceKey = getSourceAggKey({ + aggType: metricsDescriptor.type, + aggFieldName: metricsDescriptor.field, + }); + const metricStyleField = { + name: metricSourceKey, + origin: FIELD_ORIGIN.SOURCE, + }; + + const styleProperties: VectorStylePropertiesDescriptor = { + [VECTOR_STYLES.FILL_COLOR]: createDynamicFillColorDescriptor(layer, metricStyleField), + [VECTOR_STYLES.ICON_SIZE]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE]!.options as SizeDynamicOptions), + field: metricStyleField, + }, + }, + [VECTOR_STYLES.LINE_COLOR]: { + type: STYLE_TYPE.STATIC, + options: { + color: '#3d3d3d', + }, + }, + }; + + if (metric === OBSERVABILITY_METRIC_TYPE.SLA_PERCENTAGE) { + styleProperties[VECTOR_STYLES.LABEL_TEXT] = { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT]!.options as LabelDynamicOptions), + field: metricStyleField, + }, + }; + } + + return VectorLayer.createDescriptor({ + label, + query: apmSourceQuery, + sourceDescriptor: geoGridSourceDescriptor, + style: VectorStyle.createDescriptor(styleProperties), + }); +} diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/display_select.tsx b/x-pack/plugins/maps/public/layers/solution_layers/observability/display_select.tsx new file mode 100644 index 0000000000000..2c6d854db2fe9 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/display_select.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, { ChangeEvent } from 'react'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { OBSERVABILITY_LAYER_TYPE } from './layer_select'; + +export enum DISPLAY { + CHOROPLETH = 'CHOROPLETH', + CLUSTERS = 'CLUSTERS', + GRIDS = 'GRIDS', + HEATMAP = 'HEATMAP', +} + +const DISPLAY_OPTIONS = [ + { + value: DISPLAY.CHOROPLETH, + text: i18n.translate('xpack.maps.observability.choroplethLabel', { + defaultMessage: 'World boundaries', + }), + }, + { + value: DISPLAY.CLUSTERS, + text: i18n.translate('xpack.maps.observability.clustersLabel', { + defaultMessage: 'Clusters', + }), + }, + { + value: DISPLAY.GRIDS, + text: i18n.translate('xpack.maps.observability.gridsLabel', { + defaultMessage: 'Grids', + }), + }, + { + value: DISPLAY.HEATMAP, + text: i18n.translate('xpack.maps.observability.heatMapLabel', { + defaultMessage: 'Heat map', + }), + }, +]; + +interface Props { + layer: OBSERVABILITY_LAYER_TYPE | null; + value: DISPLAY; + onChange: (display: DISPLAY) => void; +} + +export function DisplaySelect(props: Props) { + function onChange(event: ChangeEvent) { + props.onChange(event.target.value as DISPLAY); + } + + if (!props.layer) { + return null; + } + + return ( + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/index.ts b/x-pack/plugins/maps/public/layers/solution_layers/observability/index.ts new file mode 100644 index 0000000000000..ae6ade86de980 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ObservabilityLayerWizardConfig } from './observability_layer_wizard'; diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/layer_select.tsx b/x-pack/plugins/maps/public/layers/solution_layers/observability/layer_select.tsx new file mode 100644 index 0000000000000..1c03cb419a849 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/layer_select.tsx @@ -0,0 +1,54 @@ +/* + * 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, { ChangeEvent } from 'react'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export enum OBSERVABILITY_LAYER_TYPE { + APM_RUM_PERFORMANCE = 'APM_RUM_PERFORMANCE', + APM_RUM_TRAFFIC = 'APM_RUM_TRAFFIC', +} + +const OBSERVABILITY_LAYER_OPTIONS = [ + { + value: OBSERVABILITY_LAYER_TYPE.APM_RUM_PERFORMANCE, + text: i18n.translate('xpack.maps.observability.apmRumPerformanceLabel', { + defaultMessage: 'APM RUM Performance', + }), + }, + { + value: OBSERVABILITY_LAYER_TYPE.APM_RUM_TRAFFIC, + text: i18n.translate('xpack.maps.observability.apmRumTrafficLabel', { + defaultMessage: 'APM RUM Traffic', + }), + }, +]; + +interface Props { + value: OBSERVABILITY_LAYER_TYPE | null; + onChange: (layer: OBSERVABILITY_LAYER_TYPE) => void; +} + +export function LayerSelect(props: Props) { + function onChange(event: ChangeEvent) { + props.onChange(event.target.value as OBSERVABILITY_LAYER_TYPE); + } + + const options = props.value + ? OBSERVABILITY_LAYER_OPTIONS + : [{ value: '', text: '' }, ...OBSERVABILITY_LAYER_OPTIONS]; + + return ( + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/metric_select.tsx b/x-pack/plugins/maps/public/layers/solution_layers/observability/metric_select.tsx new file mode 100644 index 0000000000000..8750034f74696 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/metric_select.tsx @@ -0,0 +1,89 @@ +/* + * 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, { ChangeEvent } from 'react'; +import { EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { OBSERVABILITY_LAYER_TYPE } from './layer_select'; + +export enum OBSERVABILITY_METRIC_TYPE { + TRANSACTION_DURATION = 'TRANSACTION_DURATION', + SLA_PERCENTAGE = 'SLA_PERCENTAGE', + COUNT = 'COUNT', + UNIQUE_COUNT = 'UNIQUE_COUNT', +} + +const APM_RUM_PERFORMANCE_METRIC_OPTIONS = [ + { + value: OBSERVABILITY_METRIC_TYPE.TRANSACTION_DURATION, + text: i18n.translate('xpack.maps.observability.transactionDurationLabel', { + defaultMessage: 'Transaction duraction', + }), + }, + { + value: OBSERVABILITY_METRIC_TYPE.SLA_PERCENTAGE, + text: i18n.translate('xpack.maps.observability.slaPercentageLabel', { + defaultMessage: 'SLA percentage', + }), + }, +]; + +const APM_RUM_TRAFFIC_METRIC_OPTIONS = [ + { + value: OBSERVABILITY_METRIC_TYPE.COUNT, + text: i18n.translate('xpack.maps.observability.countLabel', { + defaultMessage: 'Count', + }), + }, + { + value: OBSERVABILITY_METRIC_TYPE.UNIQUE_COUNT, + text: i18n.translate('xpack.maps.observability.uniqueCountLabel', { + defaultMessage: 'Unique count', + }), + }, +]; + +export function getMetricOptionsForLayer(layer: OBSERVABILITY_LAYER_TYPE): EuiSelectOption[] { + if (layer === OBSERVABILITY_LAYER_TYPE.APM_RUM_PERFORMANCE) { + return APM_RUM_PERFORMANCE_METRIC_OPTIONS; + } + + if (layer === OBSERVABILITY_LAYER_TYPE.APM_RUM_TRAFFIC) { + return APM_RUM_TRAFFIC_METRIC_OPTIONS; + } + + return []; +} + +interface Props { + layer: OBSERVABILITY_LAYER_TYPE | null; + value: OBSERVABILITY_METRIC_TYPE | null; + onChange: (metricType: OBSERVABILITY_METRIC_TYPE) => void; +} + +export function MetricSelect(props: Props) { + function onChange(event: ChangeEvent) { + props.onChange(event.target.value as OBSERVABILITY_METRIC_TYPE); + } + + if (!props.layer || !props.value) { + return null; + } + + return ( + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/observability_layer_template.tsx b/x-pack/plugins/maps/public/layers/solution_layers/observability/observability_layer_template.tsx new file mode 100644 index 0000000000000..7326f8459b5c7 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/observability_layer_template.tsx @@ -0,0 +1,82 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { RenderWizardArguments } from '../../layer_wizard_registry'; +import { LayerSelect, OBSERVABILITY_LAYER_TYPE } from './layer_select'; +import { getMetricOptionsForLayer, MetricSelect, OBSERVABILITY_METRIC_TYPE } from './metric_select'; +import { DisplaySelect, DISPLAY } from './display_select'; +import { createLayerDescriptor } from './create_layer_descriptor'; + +interface State { + display: DISPLAY; + layer: OBSERVABILITY_LAYER_TYPE | null; + metric: OBSERVABILITY_METRIC_TYPE | null; +} + +export class ObservabilityLayerTemplate extends Component { + state = { + layer: null, + metric: null, + display: DISPLAY.CHOROPLETH, + }; + + _onLayerChange = (layer: OBSERVABILITY_LAYER_TYPE) => { + const newState = { layer, metric: this.state.metric }; + + // Select metric when layer change invalidates selected metric. + const metricOptions = getMetricOptionsForLayer(layer); + const selectedMetricOption = metricOptions.find(option => { + return option.value === this.state.metric; + }); + if (!selectedMetricOption) { + if (metricOptions.length) { + // @ts-ignore + newState.metric = metricOptions[0].value; + } else { + newState.metric = null; + } + } + + this.setState(newState, this._previewLayer); + }; + + _onMetricChange = (metric: OBSERVABILITY_METRIC_TYPE) => { + this.setState({ metric }, this._previewLayer); + }; + + _onDisplayChange = (display: DISPLAY) => { + this.setState({ display }, this._previewLayer); + }; + + _previewLayer() { + this.props.previewLayer( + createLayerDescriptor({ + layer: this.state.layer, + metric: this.state.metric, + display: this.state.display, + }) + ); + } + + render() { + return ( + + + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/observability_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/solution_layers/observability/observability_layer_wizard.tsx new file mode 100644 index 0000000000000..3fbb3157ae62a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/observability_layer_wizard.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +import { ObservabilityLayerTemplate } from './observability_layer_template'; +import { APM_INDEX_PATTERN_ID } from './create_layer_descriptor'; +import { getIndexPatternService } from '../../../kibana_services'; + +export const ObservabilityLayerWizardConfig: LayerWizard = { + checkVisibility: async () => { + try { + await getIndexPatternService().get(APM_INDEX_PATTERN_ID); + return true; + } catch (e) { + return false; + } + }, + description: i18n.translate('xpack.maps.observability.desc', { + defaultMessage: 'APM layers', + }), + icon: 'logoObservability', + renderWizard: (renderWizardArguments: RenderWizardArguments) => { + return ; + }, + title: i18n.translate('xpack.maps.observability.title', { + defaultMessage: 'Observability', + }), +}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.js b/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.js index 58c56fe32f766..22c8293132b42 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.js @@ -7,13 +7,8 @@ import { i18n } from '@kbn/i18n'; import { AbstractESSource } from '../es_source'; import { esAggFieldsFactory } from '../../fields/es_agg_field'; -import { - AGG_DELIMITER, - AGG_TYPE, - COUNT_PROP_LABEL, - COUNT_PROP_NAME, - FIELD_ORIGIN, -} from '../../../../common/constants'; +import { AGG_TYPE, COUNT_PROP_LABEL, FIELD_ORIGIN } from '../../../../common/constants'; +import { getSourceAggKey } from '../../../../common/get_agg_key'; export class AbstractESAggSource extends AbstractESSource { constructor(descriptor, inspectorAdapters) { @@ -59,7 +54,10 @@ export class AbstractESAggSource extends AbstractESSource { } getAggKey(aggType, fieldName) { - return aggType !== AGG_TYPE.COUNT ? `${aggType}${AGG_DELIMITER}${fieldName}` : COUNT_PROP_NAME; + return getSourceAggKey({ + aggType, + aggFieldName: fieldName, + }); } getAggLabel(aggType, fieldName) { diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/create_source_editor.js index 265606dc87e0f..77d2ffb8c577e 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/create_source_editor.js +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/create_source_editor.js @@ -14,10 +14,7 @@ import { NoIndexPatternCallout } from '../../../components/no_index_pattern_call import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSpacer } from '@elastic/eui'; -import { - AGGREGATABLE_GEO_FIELD_TYPES, - getAggregatableGeoFields, -} from '../../../index_pattern_util'; +import { AGGREGATABLE_GEO_FIELD_TYPES, getFieldsWithGeoTileAgg } from '../../../index_pattern_util'; import { RenderAsSelect } from './render_as_select'; export class CreateSourceEditor extends Component { @@ -90,7 +87,7 @@ export class CreateSourceEditor extends Component { }); //make default selection - const geoFields = getAggregatableGeoFields(indexPattern.fields); + const geoFields = getFieldsWithGeoTileAgg(indexPattern.fields); if (geoFields[0]) { this._onGeoFieldSelect(geoFields[0].name); } @@ -145,7 +142,7 @@ export class CreateSourceEditor extends Component { onChange={this._onGeoFieldSelect} fields={ this.state.indexPattern - ? getAggregatableGeoFields(this.state.indexPattern.fields) + ? getFieldsWithGeoTileAgg(this.state.indexPattern.fields) : undefined } /> diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js index 17fad2f2e6453..053af4bfebe61 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -35,13 +35,14 @@ export const heatmapTitle = i18n.translate('xpack.maps.source.esGridHeatmapTitle export class ESGeoGridSource extends AbstractESAggSource { static type = SOURCE_TYPES.ES_GEO_GRID; - static createDescriptor({ indexPatternId, geoField, requestType, resolution }) { + static createDescriptor({ indexPatternId, geoField, metrics, requestType, resolution }) { return { type: ESGeoGridSource.type, id: uuid(), - indexPatternId: indexPatternId, - geoField: geoField, - requestType: requestType, + indexPatternId, + geoField, + metrics: metrics ? metrics : [], + requestType, resolution: resolution ? resolution : GRID_RESOLUTION.COARSE, }; } diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/create_source_editor.js index a4af1a3c19c83..78c16130891b8 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/create_source_editor.js +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/create_source_editor.js @@ -14,10 +14,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFormRow, EuiCallOut } from '@elastic/eui'; -import { - AGGREGATABLE_GEO_FIELD_TYPES, - getAggregatableGeoFields, -} from '../../../index_pattern_util'; +import { AGGREGATABLE_GEO_FIELD_TYPES, getFieldsWithGeoTileAgg } from '../../../index_pattern_util'; export class CreateSourceEditor extends Component { static propTypes = { @@ -86,7 +83,7 @@ export class CreateSourceEditor extends Component { return; } - const geoFields = getAggregatableGeoFields(indexPattern.fields); + const geoFields = getFieldsWithGeoTileAgg(indexPattern.fields); this.setState({ isLoadingIndexPattern: false, indexPattern: indexPattern, @@ -128,7 +125,7 @@ export class CreateSourceEditor extends Component { } const fields = this.state.indexPattern - ? getAggregatableGeoFields(this.state.indexPattern.fields) + ? getFieldsWithGeoTileAgg(this.state.indexPattern.fields) : undefined; return ( diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/create_source_editor.js index aeb3835354f07..3a25bd90384e9 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/create_source_editor.js +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/create_source_editor.js @@ -17,7 +17,7 @@ import { ES_GEO_FIELD_TYPE, SCALING_TYPES } from '../../../../common/constants'; import { DEFAULT_FILTER_BY_MAP_BOUNDS } from './constants'; import { indexPatterns } from '../../../../../../../src/plugins/data/public'; import { ScalingForm } from './scaling_form'; -import { getTermsFields } from '../../../index_pattern_util'; +import { getTermsFields, supportsGeoTileAgg } from '../../../index_pattern_util'; function getGeoFields(fields) { return fields.filter(field => { @@ -28,13 +28,8 @@ function getGeoFields(fields) { }); } -function isGeoFieldAggregatable(indexPattern, geoFieldName) { - if (!indexPattern) { - return false; - } - - const geoField = indexPattern.fields.getByName(geoFieldName); - return geoField && geoField.aggregatable; +function doesGeoFieldSupportGeoTileAgg(indexPattern, geoFieldName) { + return indexPattern ? supportsGeoTileAgg(indexPattern.fields.getByName(geoFieldName)) : false; } const RESET_INDEX_PATTERN_STATE = { @@ -133,7 +128,7 @@ export class CreateSourceEditor extends Component { // Respect previous scaling type selection unless newly selected geo field does not support clustering. const scalingType = this.state.scalingType === SCALING_TYPES.CLUSTERS && - !isGeoFieldAggregatable(this.state.indexPattern, geoFieldName) + !doesGeoFieldSupportGeoTileAgg(this.state.indexPattern, geoFieldName) ? SCALING_TYPES.LIMIT : this.state.scalingType; this.setState( @@ -218,7 +213,7 @@ export class CreateSourceEditor extends Component { indexPatternId={this.state.indexPatternId} onChange={this._onScalingPropChange} scalingType={this.state.scalingType} - supportsClustering={isGeoFieldAggregatable( + supportsClustering={doesGeoFieldSupportGeoTileAgg( this.state.indexPattern, this.state.geoFieldName )} diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx b/x-pack/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx index d86fc6d4026e6..829c9a1ce439d 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx @@ -18,9 +18,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; // @ts-ignore import { SingleFieldSelect } from '../../../components/single_field_select'; - -// @ts-ignore -import { indexPatternService } from '../../../kibana_services'; +import { getIndexPatternService } from '../../../kibana_services'; // @ts-ignore import { ValidatedRange } from '../../../components/validated_range'; import { @@ -68,8 +66,10 @@ export class ScalingForm extends Component { async loadIndexSettings() { try { - const indexPattern = await indexPatternService.get(this.props.indexPatternId); - const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); + const indexPattern = await getIndexPatternService().get(this.props.indexPatternId); + const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings( + indexPattern!.title + ); if (this._isMounted) { this.setState({ maxInnerResultWindow, maxResultWindow }); } diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js index cb6255afd0a42..59b41c2a79532 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js @@ -12,7 +12,7 @@ import { TooltipSelector } from '../../../components/tooltip_selector'; import { getIndexPatternService } from '../../../kibana_services'; import { i18n } from '@kbn/i18n'; -import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; +import { getTermsFields, getSourceFields, supportsGeoTileAgg } from '../../../index_pattern_util'; import { SORT_ORDER } from '../../../../common/constants'; import { ESDocField } from '../../fields/es_doc_field'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -90,7 +90,7 @@ export class UpdateSourceEditor extends Component { }); this.setState({ - supportsClustering: geoField.aggregatable, + supportsClustering: supportsGeoTileAgg(geoField), sourceFields: sourceFields, termFields: getTermsFields(indexPattern.fields), //todo change term fields to use fields sortFields: indexPattern.fields.filter( diff --git a/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.js b/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.js index cb07bb0e7d2ed..a6c4afa71dbb2 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.js @@ -7,8 +7,13 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { AGG_TYPE, DEFAULT_MAX_BUCKETS_LIMIT, FIELD_ORIGIN } from '../../../../common/constants'; -import { getJoinAggKey } from '../../../../common/get_join_key'; +import { + AGG_TYPE, + DEFAULT_MAX_BUCKETS_LIMIT, + FIELD_ORIGIN, + SOURCE_TYPES, +} from '../../../../common/constants'; +import { getJoinAggKey } from '../../../../common/get_agg_key'; import { ESDocField } from '../../fields/es_doc_field'; import { AbstractESAggSource } from '../es_agg_source'; import { getField, addFieldToDSL, extractPropertiesFromBucket } from '../../util/es_agg_utils'; @@ -30,7 +35,7 @@ export function extractPropertiesMap(rawEsData, countPropertyName) { } export class ESTermSource extends AbstractESAggSource { - static type = 'ES_TERM_SOURCE'; + static type = SOURCE_TYPES.ES_TERM_SOURCE; constructor(descriptor, inspectorAdapters) { super(descriptor, inspectorAdapters); diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx index 141fabeedd3e5..3b4015641ede9 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx @@ -16,7 +16,7 @@ import { TileLayer } from '../../tile_layer'; import { getKibanaTileMap } from '../../../meta'; export const kibanaBasemapLayerWizardConfig: LayerWizard = { - checkVisibility: () => { + checkVisibility: async () => { const tilemap = getKibanaTileMap(); return !!tilemap.url; }, diff --git a/x-pack/plugins/maps/public/layers/styles/color_utils.js b/x-pack/plugins/maps/public/layers/styles/color_utils.js index 23b61b07bf871..ec90ea08adeae 100644 --- a/x-pack/plugins/maps/public/layers/styles/color_utils.js +++ b/x-pack/plugins/maps/public/layers/styles/color_utils.js @@ -9,7 +9,6 @@ import tinycolor from 'tinycolor2'; import chroma from 'chroma-js'; import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; import { ColorGradient } from './components/color_gradient'; -import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants'; import { vislibColorMaps } from '../../../../../../src/plugins/charts/public'; const GRADIENT_INTERVALS = 8; @@ -120,7 +119,15 @@ export function getLinearGradient(colorStrings) { const COLOR_PALETTES_CONFIGS = [ { id: 'palette_0', - colors: DEFAULT_FILL_COLORS.slice(0, COLOR_PALETTE_MAX_SIZE), + colors: euiPaletteColorBlind(), + }, + { + id: 'palette_20', + colors: euiPaletteColorBlind(2), + }, + { + id: 'palette_30', + colors: euiPaletteColorBlind(3), }, ]; @@ -133,7 +140,7 @@ export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map(palette => { const paletteDisplay = palette.colors.map(color => { const style = { backgroundColor: color, - width: '10%', + width: `${100 / palette.colors.length}%`, position: 'relative', height: '100%', display: 'inline-block', diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index e671f00b78381..0afc784c482c5 100644 --- a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -90,6 +90,11 @@ export class DynamicColorProperty extends DynamicStyleProperty { return this._options.type === COLOR_MAP_TYPE.CATEGORICAL; } + getNumberOfCategories() { + const colors = getColorPalette(this._options.colorCategory); + return colors ? colors.length : 0; + } + supportsMbFeatureState() { return true; } diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts index 72ca7def73908..b53623ab52edb 100644 --- a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts @@ -23,6 +23,7 @@ export interface IDynamicStyleProperty extends IStyleProperty { getFieldOrigin(): FIELD_ORIGIN | undefined; getRangeFieldMeta(): RangeFieldMeta; getCategoryFieldMeta(): CategoryFieldMeta; + getNumberOfCategories(): number; isFieldMetaEnabled(): boolean; isOrdinal(): boolean; supportsFieldMeta(): boolean; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index 8cef78f9a8f21..451a79dd3864a 100644 --- a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -7,12 +7,7 @@ import _ from 'lodash'; import { AbstractStyleProperty } from './style_property'; import { DEFAULT_SIGMA } from '../vector_style_defaults'; -import { - COLOR_PALETTE_MAX_SIZE, - STYLE_TYPE, - SOURCE_META_ID_ORIGIN, - FIELD_ORIGIN, -} from '../../../../../common/constants'; +import { STYLE_TYPE, SOURCE_META_ID_ORIGIN, FIELD_ORIGIN } from '../../../../../common/constants'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; @@ -120,6 +115,10 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return false; } + getNumberOfCategories() { + return 0; + } + hasOrdinalBreaks() { return false; } @@ -149,7 +148,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { if (this.isOrdinal()) { return this._field.getOrdinalFieldMetaRequest(); } else if (this.isCategorical()) { - return this._field.getCategoricalFieldMetaRequest(); + return this._field.getCategoricalFieldMetaRequest(this.getNumberOfCategories()); } else { return null; } @@ -190,7 +189,8 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } pluckCategoricalStyleMetaFromFeatures(features) { - if (!this.isCategorical()) { + const size = this.getNumberOfCategories(); + if (!this.isCategorical() || size <= 0) { return null; } @@ -217,7 +217,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { ordered.sort((a, b) => { return b.count - a.count; }); - const truncated = ordered.slice(0, COLOR_PALETTE_MAX_SIZE); + const truncated = ordered.slice(0, size); return { categories: truncated, }; diff --git a/x-pack/plugins/maps/public/layers/vector_layer.d.ts b/x-pack/plugins/maps/public/layers/vector_layer.d.ts index efc1f3011c687..710b95b045e71 100644 --- a/x-pack/plugins/maps/public/layers/vector_layer.d.ts +++ b/x-pack/plugins/maps/public/layers/vector_layer.d.ts @@ -9,7 +9,6 @@ import { AbstractLayer } from './layer'; import { IVectorSource } from './sources/vector_source'; import { MapFilters, - LayerDescriptor, VectorLayerDescriptor, VectorSourceRequestMeta, } from '../../common/descriptor_types'; @@ -35,7 +34,7 @@ export interface IVectorLayer extends ILayer { export class VectorLayer extends AbstractLayer implements IVectorLayer { protected readonly _style: IVectorStyle; static createDescriptor( - options: Partial, + options: Partial, mapColors?: string[] ): VectorLayerDescriptor; diff --git a/x-pack/plugins/maps/public/layers/vector_layer.js b/x-pack/plugins/maps/public/layers/vector_layer.js index 582e34bce2e98..74ddf11c6beb4 100644 --- a/x-pack/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/plugins/maps/public/layers/vector_layer.js @@ -464,7 +464,7 @@ export class VectorLayer extends AbstractLayer { } const dynamicStyleFields = dynamicStyleProps.map(dynamicStyleProp => { - return dynamicStyleProp.getField().getName(); + return `${dynamicStyleProp.getField().getName()}${dynamicStyleProp.getNumberOfCategories()}`; }); const nextMeta = { diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.d.ts b/x-pack/plugins/maps/public/selectors/map_selectors.d.ts index bc881d06f62ce..9caa151db6d5a 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.d.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.d.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AnyAction } from 'redux'; import { MapCenter } from '../../common/descriptor_types'; import { MapStoreState } from '../reducers/store'; import { MapSettings } from '../reducers/map'; import { IVectorLayer } from '../layers/vector_layer'; +import { ILayer } from '../layers/layer'; export function getHiddenLayerIds(state: MapStoreState): string[]; @@ -25,3 +25,6 @@ export function hasMapSettingsChanges(state: MapStoreState): boolean; export function isUsingSearch(state: MapStoreState): boolean; export function getSpatialFiltersLayer(state: MapStoreState): IVectorLayer; + +export function getLayerList(state: MapStoreState): ILayer[]; +export function getFittableLayers(state: MapStoreState): ILayer[]; diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.js b/x-pack/plugins/maps/public/selectors/map_selectors.js index f43c92d4c9945..38a862973623a 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.js +++ b/x-pack/plugins/maps/public/selectors/map_selectors.js @@ -251,6 +251,19 @@ export const getLayerList = createSelector( } ); +export const getFittableLayers = createSelector(getLayerList, layerList => { + return layerList.filter(layer => { + //These are the only layer-types that implement bounding-box retrieval reliably + //This will _not_ work if Maps will allow register custom layer types + const isFittable = + layer.getType() === LAYER_TYPE.VECTOR || + layer.getType() === LAYER_TYPE.BLENDED_VECTOR || + layer.getType() === LAYER_TYPE.HEATMAP; + + return isFittable && layer.isVisible(); + }); +}); + export const getHiddenLayerIds = createSelector(getLayerListRaw, layers => layers.filter(layer => !layer.visible).map(layer => layer.id) ); diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index a5b301902cc75..aeb774a224021 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, FC } from 'react'; +import { isEqual } from 'lodash'; +import React, { memo, useEffect, FC } from 'react'; import { i18n } from '@kbn/i18n'; @@ -50,132 +51,154 @@ function isWithHeader(arg: any): arg is PropsWithHeader { type Props = PropsWithHeader | PropsWithoutHeader; -export const DataGrid: FC = props => { - const { - columns, - dataTestSubj, - errorMessage, - invalidSortingColumnns, - noDataMessage, - onChangeItemsPerPage, - onChangePage, - onSort, - pagination, - setVisibleColumns, - renderCellValue, - rowCount, - sortingColumns, - status, - tableItems: data, - toastNotifications, - visibleColumns, - } = props; - - useEffect(() => { - if (invalidSortingColumnns.length > 0) { - invalidSortingColumnns.forEach(columnId => { - toastNotifications.addDanger( - i18n.translate('xpack.ml.dataGrid.invalidSortingColumnError', { - defaultMessage: `The column '{columnId}' cannot be used for sorting.`, - values: { columnId }, - }) - ); - }); - } - }, [invalidSortingColumnns, toastNotifications]); - - if (status === INDEX_STATUS.LOADED && data.length === 0) { - return ( -
- {isWithHeader(props) && } - -

- {i18n.translate('xpack.ml.dataGrid.IndexNoDataCalloutBody', { - defaultMessage: - 'The query for the index returned no results. Please make sure you have sufficient permissions, the index contains documents and your query is not too restrictive.', +export const DataGrid: FC = memo( + props => { + const { + columns, + dataTestSubj, + errorMessage, + invalidSortingColumnns, + noDataMessage, + onChangeItemsPerPage, + onChangePage, + onSort, + pagination, + setVisibleColumns, + renderCellValue, + rowCount, + sortingColumns, + status, + tableItems: data, + toastNotifications, + visibleColumns, + } = props; + + useEffect(() => { + if (invalidSortingColumnns.length > 0) { + invalidSortingColumnns.forEach(columnId => { + toastNotifications.addDanger( + i18n.translate('xpack.ml.dataGrid.invalidSortingColumnError', { + defaultMessage: `The column '{columnId}' cannot be used for sorting.`, + values: { columnId }, + }) + ); + }); + } + }, [invalidSortingColumnns, toastNotifications]); + + if (status === INDEX_STATUS.LOADED && data.length === 0) { + return ( +

+ {isWithHeader(props) && } + - -
- ); - } + color="primary" + > +

+ {i18n.translate('xpack.ml.dataGrid.IndexNoDataCalloutBody', { + defaultMessage: + 'The query for the index returned no results. Please make sure you have sufficient permissions, the index contains documents and your query is not too restrictive.', + })} +

+
+
+ ); + } - if (noDataMessage !== '') { - return ( -
- {isWithHeader(props) && } - -

{noDataMessage}

-
-
- ); - } - - return ( -
- {isWithHeader(props) && ( - - - - - - - {(copy: () => void) => ( - - )} - - - - )} - {status === INDEX_STATUS.ERROR && ( -
+ if (noDataMessage !== '') { + return ( +
+ {isWithHeader(props) && } - - {errorMessage} - +

{noDataMessage}

-
- )} - -
- ); -}; + ); + } + + return ( +
+ {isWithHeader(props) && ( + + + + + + + {(copy: () => void) => ( + + )} + + + + )} + {status === INDEX_STATUS.ERROR && ( +
+ + + {errorMessage} + + + +
+ )} + +
+ ); + }, + (prevProps, nextProps) => isEqual(pickProps(prevProps), pickProps(nextProps)) +); + +function pickProps(props: Props) { + return [ + props.columns, + props.dataTestSubj, + props.errorMessage, + props.invalidSortingColumnns, + props.noDataMessage, + props.pagination, + props.rowCount, + props.sortingColumns, + props.status, + props.tableItems, + props.visibleColumns, + ...(isWithHeader(props) + ? [props.copyToClipboard, props.copyToClipboardDescription, props.title] + : []), + ]; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx index 2ab8cb4a78d86..907297cf69bfc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx @@ -66,15 +66,21 @@ export const getTaskStateBadge = ( export const progressColumn = { name: i18n.translate('xpack.ml.dataframe.analyticsList.progress', { - defaultMessage: 'Progress', + defaultMessage: 'Progress per Step', }), sortable: (item: DataFrameAnalyticsListRow) => getDataFrameAnalyticsProgress(item.stats), truncateText: true, render(item: DataFrameAnalyticsListRow) { - const progress = getDataFrameAnalyticsProgress(item.stats); + const totalSteps = item.stats.progress.length; + let step = 0; + let progress = 0; - if (progress === undefined) { - return null; + for (const progressStep of item.stats.progress) { + step++; + progress = progressStep.progress_percent; + if (progressStep.progress_percent < 100) { + break; + } } // For now all analytics jobs are batch jobs. @@ -98,6 +104,11 @@ export const progressColumn = { {`${progress}%`} + + + {step}/{totalSteps} + + )} {!isBatchTransform && ( @@ -118,7 +129,7 @@ export const progressColumn = { ); }, - width: '100px', + width: '130px', 'data-test-subj': 'mlAnalyticsTableColumnProgress', }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx index 43ef6b36c3972..adb6822c524ab 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx @@ -171,17 +171,35 @@ export const ExpandedRow: FC = ({ item }) => { position: 'left', }; + const totalSteps = item.stats.progress.length; + let step = 0; + for (const progressStep of item.stats.progress) { + step++; + if (progressStep.progress_percent < 100) { + break; + } + } + const progress: SectionConfig = { title: i18n.translate( 'xpack.ml.dataframe.analyticsList.expandedRow.tabs.jobSettings.progress', { defaultMessage: 'Progress' } ), - items: item.stats.progress.map(s => { - return { - title: s.phase, - description: , - }; - }), + items: [ + { + title: i18n.translate( + 'xpack.ml.dataframe.analyticsList.expandedRow.tabs.jobSettings.step', + { defaultMessage: 'Step' } + ), + description: `${step}/${totalSteps}`, + }, + ...item.stats.progress.map(s => { + return { + title: s.phase, + description: , + }; + }), + ], position: 'left', }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts index 6c2030daec39d..10d306c37d114 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts @@ -19,7 +19,7 @@ import { getRichDetectors } from './util/general'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; export class MultiMetricJobCreator extends JobCreator { - // a multi metric job has one optional overall partition field + // a multi-metric job has one optional overall partition field // which is the same for all detectors. private _splitField: SplitField = null; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index c487341ab0c36..6d061c2df9ad9 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -298,7 +298,7 @@ export function getJobCreatorTitle(jobCreator: JobCreatorType) { }); case JOB_TYPE.MULTI_METRIC: return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.multiMetric', { - defaultMessage: 'Multi metric', + defaultMessage: 'Multi-metric', }); case JOB_TYPE.POPULATION: return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.population', { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx index 2a898843dda57..8b9dbe23f0920 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx @@ -22,7 +22,7 @@ export const Description: FC = memo(({ children }) => { description={ } > diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx index f8e7275cf15bb..3bcac1cf6876c 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx @@ -40,7 +40,7 @@ export const SingleMetricSettings: FC = ({ setIsValid }) => { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx index 562ef780bd17b..3bfe0569e75be 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx @@ -106,15 +106,15 @@ export const Page: FC = () => { icon: { type: 'createMultiMetricJob', ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.multiMetricAriaLabel', { - defaultMessage: 'Multi metric job', + defaultMessage: 'Multi-metric job', }), }, title: i18n.translate('xpack.ml.newJob.wizard.jobType.multiMetricTitle', { - defaultMessage: 'Multi metric', + defaultMessage: 'Multi-metric', }), description: i18n.translate('xpack.ml.newJob.wizard.jobType.multiMetricDescription', { defaultMessage: - 'Detect anomalies in multiple metrics by splitting a time series by a categorical field.', + 'Detect anomalies with one or more metrics and optionally split the analysis.', }), id: 'mlJobTypeLinkMultiMetricJob', }, @@ -208,7 +208,7 @@ export const Page: FC = () => {

@@ -217,8 +217,8 @@ export const Page: FC = () => {

@@ -244,16 +244,6 @@ export const Page: FC = () => { />

- - -

- -

-
- diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx index 377ec84623480..bae9c592b94c4 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx @@ -109,7 +109,7 @@ export const JobSettingsForm: FC = ({ description={ } > @@ -230,7 +230,7 @@ export const JobSettingsForm: FC = ({ description={ } > @@ -263,7 +263,7 @@ export const JobSettingsForm: FC = ({ onSubmit(formState); }} aria-label={i18n.translate('xpack.ml.newJob.recognize.createJobButtonAriaLabel', { - defaultMessage: 'Create Job', + defaultMessage: 'Create job', })} > {saveState === SAVE_STATE.NOT_SAVED && ( diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index 74dbe055fead3..288914f702728 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -34,3 +34,10 @@ export const DATA_VISUALIZER_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ }), href: '#/datavisualizer', }); + +export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.createJobsBreadcrumbLabel', { + defaultMessage: 'Create job', + }), + href: '#/jobs/new_job', +}); diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx index 12687fd71edc5..2cd40cbcd95e6 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx @@ -13,14 +13,19 @@ import { basicResolvers } from '../../resolvers'; import { Page } from '../../../jobs/new_job/recognize'; import { checkViewOrCreateJobs } from '../../../jobs/new_job/recognize/resolvers'; import { mlJobService } from '../../../services/job_service'; -import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../../breadcrumbs'; +import { + ANOMALY_DETECTION_BREADCRUMB, + CREATE_JOB_BREADCRUMB, + ML_BREADCRUMB, +} from '../../breadcrumbs'; const breadcrumbs = [ ML_BREADCRUMB, ANOMALY_DETECTION_BREADCRUMB, + CREATE_JOB_BREADCRUMB, { text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectIndexOrSearchLabelRecognize', { - defaultMessage: 'Select index or search', + defaultMessage: 'Recognized index', }), href: '', }, diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx index 1c91d7e94b241..14df9a1d44a85 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx @@ -16,20 +16,17 @@ import { JOB_TYPE } from '../../../../../common/constants/new_job'; import { mlJobService } from '../../../services/job_service'; import { loadNewJobCapabilities } from '../../../services/new_job_capabilities_service'; import { checkCreateJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; -import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../../breadcrumbs'; +import { + ANOMALY_DETECTION_BREADCRUMB, + CREATE_JOB_BREADCRUMB, + ML_BREADCRUMB, +} from '../../breadcrumbs'; interface WizardPageProps extends PageProps { jobType: JOB_TYPE; } -const createJobBreadcrumbs = { - text: i18n.translate('xpack.ml.jobsBreadcrumbs.createJobLabel', { - defaultMessage: 'Create job', - }), - href: '#/jobs/new_job', -}; - -const baseBreadcrumbs = [ML_BREADCRUMB, ANOMALY_DETECTION_BREADCRUMB, createJobBreadcrumbs]; +const baseBreadcrumbs = [ML_BREADCRUMB, ANOMALY_DETECTION_BREADCRUMB, CREATE_JOB_BREADCRUMB]; const singleMetricBreadcrumbs = [ ...baseBreadcrumbs, @@ -45,7 +42,7 @@ const multiMetricBreadcrumbs = [ ...baseBreadcrumbs, { text: i18n.translate('xpack.ml.jobsBreadcrumbs.multiMetricLabel', { - defaultMessage: 'Multi metric', + defaultMessage: 'Multi-metric', }), href: '', }, diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/import_modal/utils.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/import_modal/utils.js index b8a14650d3fd5..d24577826838e 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/import_modal/utils.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/import_modal/utils.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -const icalendar = require('icalendar'); +import icalendar from 'icalendar'; import moment from 'moment'; import { generateTempId } from '../utils'; diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index 8070f94a1264d..c23d042822816 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -13,6 +13,7 @@ import { MlSetupDependencies, MlStartDependencies, } from './plugin'; +import { getMetricChangeDescription } from './application/formatters/metric_change_description'; export const plugin: PluginInitializer< MlPluginSetup, @@ -21,4 +22,4 @@ export const plugin: PluginInitializer< MlStartDependencies > = () => new MlPlugin(); -export { MlPluginSetup, MlPluginStart }; +export { MlPluginSetup, MlPluginStart, getMetricChangeDescription }; diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache_ecs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache_ecs/manifest.json index 031cf05ca39cd..c4b6b18f56bc8 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache_ecs/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache_ecs/manifest.json @@ -1,7 +1,7 @@ { "id": "apache_ecs", "title": "Apache access logs", - "description": "Find unusual activity in HTTP access logs from filebeat (ECS)", + "description": "Find unusual activity in HTTP access logs from filebeat (ECS).", "type": "Web Access Logs", "logoFile": "logo.json", "defaultIndexPattern": "filebeat-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apm_transaction/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apm_transaction/manifest.json index 5eac6d090d1b9..5e185e80a6038 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apm_transaction/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apm_transaction/manifest.json @@ -1,7 +1,7 @@ { "id": "apm_transaction", "title": "APM", - "description": "Detect anomalies in high mean of transaction duration (ECS)", + "description": "Detect anomalies in high mean of transaction duration (ECS).", "type": "Transaction data", "logoFile": "logo.json", "defaultIndexPattern": "apm-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/manifest.json index 345f061db9de1..7f407c4f5f713 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/manifest.json @@ -1,7 +1,7 @@ { "id": "auditbeat_process_docker_ecs", "title": "Auditbeat docker processes", - "description": "Detect unusual processes in docker containers from auditd data (ECS)", + "description": "Detect unusual processes in docker containers from auditd data (ECS).", "type": "Auditbeat data", "logoFile": "logo.json", "defaultIndexPattern": "auditbeat-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/manifest.json index 94254189be172..3f883e0198817 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/manifest.json @@ -1,7 +1,7 @@ { "id": "auditbeat_process_hosts_ecs", "title": "Auditbeat host processes", - "description": "Detect unusual processes on hosts from auditd data (ECS)", + "description": "Detect unusual processes on hosts from auditd data (ECS).", "type": "Auditbeat data", "logoFile": "logo.json", "defaultIndexPattern": "auditbeat-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/logs_ui_analysis/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/logs_ui_analysis/manifest.json index 28fd590e68363..4c97acedcedcf 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/logs_ui_analysis/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/logs_ui_analysis/manifest.json @@ -1,7 +1,7 @@ { "id": "logs_ui_analysis", "title": "Log Analysis", - "description": "Detect anomalies in log entries via the Logs UI", + "description": "Detect anomalies in log entries via the Logs UI.", "type": "Logs", "logoFile": "logo.json", "jobs": [ diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/logs_ui_categories/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/logs_ui_categories/manifest.json index b31ddacde8b6d..d3faa27c29df7 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/logs_ui_categories/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/logs_ui_categories/manifest.json @@ -1,7 +1,7 @@ { "id": "logs_ui_categories", "title": "Log entry categories", - "description": "Detect anomalies in count of log entries by category", + "description": "Detect anomalies in count of log entries by category.", "type": "Logs", "logoFile": "logo.json", "jobs": [ diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metricbeat_system_ecs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metricbeat_system_ecs/manifest.json index 79a201a797008..499896ced7deb 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/metricbeat_system_ecs/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metricbeat_system_ecs/manifest.json @@ -2,7 +2,7 @@ "id": "metricbeat_system_ecs", "title": "Metricbeat System", "description": "Detect anomalies in Metricbeat System data (ECS)", - "type": "Metricbeat data", + "type": "Metricbeat data.", "logoFile": "logo.json", "defaultIndexPattern": "metricbeat-*", "query": { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/nginx_ecs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/nginx_ecs/manifest.json index bd31fcd23f504..c39e4e5e4faf6 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/nginx_ecs/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/nginx_ecs/manifest.json @@ -1,7 +1,7 @@ { "id": "nginx_ecs", "title": "Nginx access logs", - "description": "Find unusual activity in HTTP access logs from filebeat (ECS)", + "description": "Find unusual activity in HTTP access logs from filebeat (ECS).", "type": "Web Access Logs", "logoFile": "logo.json", "defaultIndexPattern": "filebeat-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json index dea2875b15160..4c0186023b458 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json @@ -1,7 +1,7 @@ { "id": "sample_data_ecommerce", "title": "Kibana sample data eCommerce", - "description": "Find anomalies in eCommerce total sales data", + "description": "Find anomalies in eCommerce total sales data.", "type": "Sample Dataset", "logoFile": "logo.json", "defaultIndexPattern": "kibana_sample_data_ecommerce", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json index 9c3aceba33f38..a0b47e7135312 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json @@ -1,7 +1,7 @@ { "id": "sample_data_weblogs", "title": "Kibana sample data web logs", - "description": "Find anomalies in Kibana sample web logs data", + "description": "Find anomalies in Kibana sample web logs data.", "type": "Sample Dataset", "logoFile": "logo.json", "defaultIndexPattern": "kibana_sample_data_logs", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/manifest.json index 6238f07413323..3c7b1c7cfffd4 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/manifest.json @@ -1,7 +1,7 @@ { "id": "siem_auditbeat", "title": "SIEM Auditbeat", - "description": "Detect suspicious network activity and unusual processes in Auditbeat data (beta)", + "description": "Detect suspicious network activity and unusual processes in Auditbeat data (beta).", "type": "Auditbeat data", "logoFile": "logo.json", "defaultIndexPattern": "auditbeat-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/manifest.json index 4665b695bbf84..4b86752e45a92 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat_auth/manifest.json @@ -1,7 +1,7 @@ { "id": "siem_auditbeat_auth", "title": "SIEM Auditbeat Authentication", - "description": "Detect suspicious authentication events in Auditbeat data (beta)", + "description": "Detect suspicious authentication events in Auditbeat data (beta).", "type": "Auditbeat data", "logoFile": "logo.json", "defaultIndexPattern": "auditbeat-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_packetbeat/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_packetbeat/manifest.json index 455a6658ac23f..9109cbc15ca6f 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_packetbeat/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_packetbeat/manifest.json @@ -1,7 +1,7 @@ { "id": "siem_packetbeat", "title": "SIEM Packetbeat", - "description": "Detect suspicious network activity in Packetbeat data (beta)", + "description": "Detect suspicious network activity in Packetbeat data (beta).", "type": "Packetbeat data", "logoFile": "logo.json", "defaultIndexPattern": "packetbeat-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_packetbeat/ml/datafeed_packetbeat_rare_user_agent.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_packetbeat/ml/datafeed_packetbeat_rare_user_agent.json index c5938aa200cd4..5986c326ea80f 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_packetbeat/ml/datafeed_packetbeat_rare_user_agent.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_packetbeat/ml/datafeed_packetbeat_rare_user_agent.json @@ -7,7 +7,7 @@ "query": { "bool": { "filter": [ - {"term": {"event.dataset": "dns"}}, + {"term": {"event.dataset": "http"}}, {"term": {"agent.type": "packetbeat"}} ], "must_not": [ diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/manifest.json index 501a900d5badd..682b9a833f23f 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat/manifest.json @@ -1,7 +1,7 @@ { "id": "siem_winlogbeat", "title": "SIEM Winlogbeat", - "description": "Detect unusual processes and network activity in Winlogbeat data (beta)", + "description": "Detect unusual processes and network activity in Winlogbeat data (beta).", "type": "Winlogbeat data", "logoFile": "logo.json", "defaultIndexPattern": "winlogbeat-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/manifest.json index 89feaf73051b3..b5e65e9638eb2 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/manifest.json @@ -1,7 +1,7 @@ { "id": "siem_winlogbeat_auth", "title": "SIEM Winlogbeat Authentication", - "description": "Detect suspicious authentication events in Winlogbeat data (beta)", + "description": "Detect suspicious authentication events in Winlogbeat data (beta).", "type": "Winlogbeat data", "logoFile": "logo.json", "defaultIndexPattern": "winlogbeat-*", diff --git a/x-pack/plugins/monitoring/public/components/sparkline/__mocks__/plugins/xpack_main/jquery_flot.js b/x-pack/plugins/monitoring/public/components/sparkline/__mocks__/plugins/xpack_main/jquery_flot.js index 02165b659407f..31dbddfcd17ea 100644 --- a/x-pack/plugins/monitoring/public/components/sparkline/__mocks__/plugins/xpack_main/jquery_flot.js +++ b/x-pack/plugins/monitoring/public/components/sparkline/__mocks__/plugins/xpack_main/jquery_flot.js @@ -6,15 +6,12 @@ /* eslint-env jest */ -function $() { +export function $() { return { on: jest.fn(), off: jest.fn(), + plot: () => ({ + shutdown: jest.fn(), + }), }; } - -$.plot = () => ({ - shutdown: jest.fn(), -}); - -module.exports = $; diff --git a/x-pack/plugins/monitoring/public/directives/main/index.html b/x-pack/plugins/monitoring/public/directives/main/index.html index f5c4bf5c757af..39d357813b3f2 100644 --- a/x-pack/plugins/monitoring/public/directives/main/index.html +++ b/x-pack/plugins/monitoring/public/directives/main/index.html @@ -240,7 +240,7 @@ i18n-id="xpack.monitoring.logstashNavigation.pipelinesLinkText" i18n-default-message="Pipelines" > - + - + - + { + it('logs a warning if elasticsearch.username is set to "kibana"', () => { + const settings = { elasticsearch: { username: 'kibana' } }; + + const log = sinon.spy(); + transformDeprecations(settings, fromPath, log); + expect(log.called).to.be(true); + }); + + it('does not log a warning if elasticsearch.username is set to something besides "elastic" or "kibana"', () => { const settings = { elasticsearch: { username: 'otheruser' } }; const log = sinon.spy(); diff --git a/x-pack/plugins/monitoring/server/config.ts b/x-pack/plugins/monitoring/server/config.ts index 6e5092a112744..ad5bf95090186 100644 --- a/x-pack/plugins/monitoring/server/config.ts +++ b/x-pack/plugins/monitoring/server/config.ts @@ -119,7 +119,7 @@ export const configSchema = schema.object({ if (rawConfig === 'elastic') { return ( 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + - 'privilege-related issues. You should use the "kibana" user instead.' + 'privilege-related issues. You should use the "kibana_system" user instead.' ); } }, diff --git a/x-pack/plugins/monitoring/server/deprecations.ts b/x-pack/plugins/monitoring/server/deprecations.ts index 3a3ec6ac799d2..d40837885e198 100644 --- a/x-pack/plugins/monitoring/server/deprecations.ts +++ b/x-pack/plugins/monitoring/server/deprecations.ts @@ -59,7 +59,11 @@ export const deprecations = ({ if (es) { if (es.username === 'elastic') { logger( - `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana" user instead.` + `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana_system" user instead.` + ); + } else if (es.username === 'kibana') { + logger( + `Setting [${fromPath}.username] to "kibana" is deprecated. You should use the "kibana_system" user instead.` ); } } diff --git a/x-pack/plugins/observability/common/annotations.ts b/x-pack/plugins/observability/common/annotations.ts new file mode 100644 index 0000000000000..6aea4d3d92f9b --- /dev/null +++ b/x-pack/plugins/observability/common/annotations.ts @@ -0,0 +1,51 @@ +/* + * 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 t from 'io-ts'; +import { dateAsStringRt } from '../../apm/common/runtime_types/date_as_string_rt'; + +export const createAnnotationRt = t.intersection([ + t.type({ + annotation: t.type({ + type: t.string, + }), + '@timestamp': dateAsStringRt, + message: t.string, + }), + t.partial({ + tags: t.array(t.string), + service: t.partial({ + name: t.string, + environment: t.string, + version: t.string, + }), + }), +]); + +export const deleteAnnotationRt = t.type({ + id: t.string, +}); + +export const getAnnotationByIdRt = t.type({ + id: t.string, +}); + +export interface Annotation { + annotation: { + type: string; + }; + tags?: string[]; + message: string; + service?: { + name?: string; + environment?: string; + version?: string; + }; + event: { + created: string; + }; + '@timestamp': string; +} diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 438b9ddea4734..8e2cfe980039c 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -2,6 +2,10 @@ "id": "observability", "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "observability"], - "ui": true + "configPath": [ + "xpack", + "observability" + ], + "ui": true, + "server": true } diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts new file mode 100644 index 0000000000000..78550b781b411 --- /dev/null +++ b/x-pack/plugins/observability/server/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginInitializerContext } from 'src/core/server'; +import { ObservabilityPlugin, ObservabilityPluginSetup } from './plugin'; +import { createOrUpdateIndex, MappingsDefinition } from './utils/create_or_update_index'; +import { ScopedAnnotationsClient } from './lib/annotations/bootstrap_annotations'; + +export const config = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + annotations: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + index: schema.string({ defaultValue: 'observability-annotations' }), + }), + }), +}; + +export type ObservabilityConfig = TypeOf; + +export const plugin = (initContext: PluginInitializerContext) => + new ObservabilityPlugin(initContext); + +export { + createOrUpdateIndex, + MappingsDefinition, + ObservabilityPluginSetup, + ScopedAnnotationsClient, +}; diff --git a/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts b/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts new file mode 100644 index 0000000000000..58639ef084ce6 --- /dev/null +++ b/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts @@ -0,0 +1,42 @@ +/* + * 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 { CoreSetup, PluginInitializerContext, KibanaRequest } from 'kibana/server'; +import { PromiseReturnType } from '../../../typings/common'; +import { createAnnotationsClient } from './create_annotations_client'; +import { registerAnnotationAPIs } from './register_annotation_apis'; + +interface Params { + index: string; + core: CoreSetup; + context: PluginInitializerContext; +} + +export type ScopedAnnotationsClientFactory = PromiseReturnType< + typeof bootstrapAnnotations +>['getScopedAnnotationsClient']; + +export type ScopedAnnotationsClient = ReturnType; +export type AnnotationsAPI = PromiseReturnType; + +export async function bootstrapAnnotations({ index, core, context }: Params) { + const logger = context.logger.get('annotations'); + + registerAnnotationAPIs({ + core, + index, + logger, + }); + + return { + getScopedAnnotationsClient: (request: KibanaRequest) => { + return createAnnotationsClient({ + index, + apiCaller: core.elasticsearch.dataClient.asScoped(request).callAsCurrentUser, + logger, + }); + }, + }; +} diff --git a/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts b/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts new file mode 100644 index 0000000000000..3f2604468e17c --- /dev/null +++ b/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts @@ -0,0 +1,106 @@ +/* + * 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 { APICaller, Logger } from 'kibana/server'; +import * as t from 'io-ts'; +import { Client } from 'elasticsearch'; +import { + createAnnotationRt, + deleteAnnotationRt, + Annotation, + getAnnotationByIdRt, +} from '../../../common/annotations'; +import { PromiseReturnType } from '../../../typings/common'; +import { createOrUpdateIndex } from '../../utils/create_or_update_index'; +import { mappings } from './mappings'; + +type CreateParams = t.TypeOf; +type DeleteParams = t.TypeOf; +type GetByIdParams = t.TypeOf; + +interface IndexDocumentResponse { + _shards: { + total: number; + failed: number; + successful: number; + }; + _index: string; + _type: string; + _id: string; + _version: number; + _seq_no: number; + _primary_term: number; + result: string; +} + +export function createAnnotationsClient(params: { + index: string; + apiCaller: APICaller; + logger: Logger; +}) { + const { index, apiCaller, logger } = params; + + const initIndex = () => + createOrUpdateIndex({ + index, + mappings, + apiCaller, + logger, + }); + + return { + get index() { + return index; + }, + create: async ( + createParams: CreateParams + ): Promise<{ _id: string; _index: string; _source: Annotation }> => { + const indexExists = await apiCaller('indices.exists', { + index, + }); + + if (!indexExists) { + await initIndex(); + } + + const annotation = { + ...createParams, + event: { + created: new Date().toISOString(), + }, + }; + + const response = (await apiCaller('index', { + index, + body: annotation, + refresh: 'wait_for', + })) as IndexDocumentResponse; + + return apiCaller('get', { + index, + id: response._id, + }); + }, + getById: async (getByIdParams: GetByIdParams) => { + const { id } = getByIdParams; + + return apiCaller('get', { + id, + index, + }); + }, + delete: async (deleteParams: DeleteParams) => { + const { id } = deleteParams; + + const response = (await apiCaller('delete', { + index, + id, + refresh: 'wait_for', + })) as PromiseReturnType; + return response; + }, + }; +} diff --git a/x-pack/plugins/observability/server/lib/annotations/mappings.ts b/x-pack/plugins/observability/server/lib/annotations/mappings.ts new file mode 100644 index 0000000000000..db85f2d18df1d --- /dev/null +++ b/x-pack/plugins/observability/server/lib/annotations/mappings.ts @@ -0,0 +1,47 @@ +/* + * 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 mappings = { + dynamic: 'strict', + properties: { + annotation: { + properties: { + type: { + type: 'keyword', + }, + }, + }, + message: { + type: 'text', + }, + tags: { + type: 'keyword', + }, + '@timestamp': { + type: 'date', + }, + event: { + properties: { + created: { + type: 'date', + }, + }, + }, + service: { + properties: { + name: { + type: 'keyword', + }, + environment: { + type: 'keyword', + }, + version: { + type: 'keyword', + }, + }, + }, + }, +} as const; diff --git a/x-pack/plugins/observability/server/lib/annotations/register_annotation_apis.ts b/x-pack/plugins/observability/server/lib/annotations/register_annotation_apis.ts new file mode 100644 index 0000000000000..3c29822acd6dd --- /dev/null +++ b/x-pack/plugins/observability/server/lib/annotations/register_annotation_apis.ts @@ -0,0 +1,109 @@ +/* + * 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 t from 'io-ts'; +import { schema } from '@kbn/config-schema'; +import { CoreSetup, RequestHandler, Logger } from 'kibana/server'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { isLeft } from 'fp-ts/lib/Either'; +import { + getAnnotationByIdRt, + createAnnotationRt, + deleteAnnotationRt, +} from '../../../common/annotations'; +import { ScopedAnnotationsClient } from './bootstrap_annotations'; +import { createAnnotationsClient } from './create_annotations_client'; + +const unknowns = schema.object({}, { unknowns: 'allow' }); + +export function registerAnnotationAPIs({ + core, + index, + logger, +}: { + core: CoreSetup; + index: string; + logger: Logger; +}) { + function wrapRouteHandler>( + types: TType, + handler: (params: { data: t.TypeOf; client: ScopedAnnotationsClient }) => Promise + ): RequestHandler { + return async (...args: Parameters) => { + const [, request, response] = args; + + const rt = types; + + const data = { + body: request.body, + query: request.query, + params: request.params, + }; + + const validation = rt.decode(data); + + if (isLeft(validation)) { + return response.badRequest({ + body: PathReporter.report(validation).join(', '), + }); + } + + const apiCaller = core.elasticsearch.dataClient.asScoped(request).callAsCurrentUser; + + const client = createAnnotationsClient({ + index, + apiCaller, + logger, + }); + + const res = await handler({ + data: validation.right, + client, + }); + + return response.ok({ + body: res, + }); + }; + } + + const router = core.http.createRouter(); + + router.post( + { + path: '/api/observability/annotation', + validate: { + body: unknowns, + }, + }, + wrapRouteHandler(t.type({ body: createAnnotationRt }), ({ data, client }) => { + return client.create(data.body); + }) + ); + + router.delete( + { + path: '/api/observability/annotation/{id}', + validate: { + params: unknowns, + }, + }, + wrapRouteHandler(t.type({ params: deleteAnnotationRt }), ({ data, client }) => { + return client.delete(data.params); + }) + ); + + router.get( + { + path: '/api/observability/annotation/{id}', + validate: { + params: unknowns, + }, + }, + wrapRouteHandler(t.type({ params: getAnnotationByIdRt }), ({ data, client }) => { + return client.getById(data.params); + }) + ); +} diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts new file mode 100644 index 0000000000000..86cac2f340e44 --- /dev/null +++ b/x-pack/plugins/observability/server/plugin.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; +import { take } from 'rxjs/operators'; +import { ObservabilityConfig } from '.'; +import { + bootstrapAnnotations, + ScopedAnnotationsClient, + ScopedAnnotationsClientFactory, + AnnotationsAPI, +} from './lib/annotations/bootstrap_annotations'; + +type LazyScopedAnnotationsClientFactory = ( + ...args: Parameters +) => Promise; + +export interface ObservabilityPluginSetup { + getScopedAnnotationsClient: LazyScopedAnnotationsClientFactory; +} + +export class ObservabilityPlugin implements Plugin { + constructor(private readonly initContext: PluginInitializerContext) { + this.initContext = initContext; + } + + public async setup(core: CoreSetup, plugins: {}): Promise { + const config$ = this.initContext.config.create(); + + const config = await config$.pipe(take(1)).toPromise(); + + let annotationsApiPromise: Promise | undefined; + + if (config.annotations.enabled) { + annotationsApiPromise = bootstrapAnnotations({ + core, + index: config.annotations.index, + context: this.initContext, + }).catch(err => { + const logger = this.initContext.logger.get('annotations'); + logger.warn(err); + throw err; + }); + } + + return { + getScopedAnnotationsClient: async (...args) => { + const api = await annotationsApiPromise; + return api?.getScopedAnnotationsClient(...args); + }, + }; + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/plugins/observability/server/utils/create_or_update_index.ts b/x-pack/plugins/observability/server/utils/create_or_update_index.ts new file mode 100644 index 0000000000000..2c6f3dbefdeb1 --- /dev/null +++ b/x-pack/plugins/observability/server/utils/create_or_update_index.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import pRetry from 'p-retry'; +import { Logger, APICaller } from 'src/core/server'; + +export interface MappingsObject { + type: string; + ignore_above?: number; + scaling_factor?: number; + ignore_malformed?: boolean; + coerce?: boolean; + fields?: Record; +} + +export interface MappingsDefinition { + dynamic?: boolean | 'strict'; + properties: Record; + dynamic_templates?: any[]; +} + +export async function createOrUpdateIndex({ + index, + mappings, + apiCaller, + logger, +}: { + index: string; + mappings: MappingsDefinition; + apiCaller: APICaller; + logger: Logger; +}) { + try { + /* + * In some cases we could be trying to create an index before ES is ready. + * When this happens, we retry creating the index with exponential backoff. + * We use retry's default formula, meaning that the first retry happens after 2s, + * the 5th after 32s, and the final attempt after around 17m. If the final attempt fails, + * the error is logged to the console. + * See https://github.com/sindresorhus/p-retry and https://github.com/tim-kos/node-retry. + */ + await pRetry( + async () => { + const indexExists = await apiCaller('indices.exists', { index }); + const result = indexExists + ? await updateExistingIndex({ + index, + apiCaller, + mappings, + }) + : await createNewIndex({ + index, + apiCaller, + mappings, + }); + + if (!result.acknowledged) { + const resultError = result && result.error && JSON.stringify(result.error); + throw new Error(resultError); + } + }, + { + onFailedAttempt: e => { + logger.warn(`Could not create index: '${index}'. Retrying...`); + logger.warn(e); + }, + } + ); + } catch (e) { + logger.error(`Could not create index: '${index}'. Error: ${e.message}.`); + } +} + +function createNewIndex({ + index, + apiCaller, + mappings, +}: { + index: string; + apiCaller: APICaller; + mappings: MappingsDefinition; +}) { + return apiCaller('indices.create', { + index, + body: { + // auto_expand_replicas: Allows cluster to not have replicas for this index + settings: { 'index.auto_expand_replicas': '0-1' }, + mappings, + }, + }); +} + +function updateExistingIndex({ + index, + apiCaller, + mappings, +}: { + index: string; + apiCaller: APICaller; + mappings: MappingsDefinition; +}) { + return apiCaller('indices.putMapping', { + index, + body: mappings, + }); +} diff --git a/x-pack/plugins/observability/typings/common.ts b/x-pack/plugins/observability/typings/common.ts new file mode 100644 index 0000000000000..b4a90934a9f49 --- /dev/null +++ b/x-pack/plugins/observability/typings/common.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 type PromiseReturnType = Func extends (...args: any[]) => Promise + ? Value + : Func; diff --git a/x-pack/plugins/security/common/model/user.ts b/x-pack/plugins/security/common/model/user.ts index e1bae2fc44e58..5c852e7a8f03d 100644 --- a/x-pack/plugins/security/common/model/user.ts +++ b/x-pack/plugins/security/common/model/user.ts @@ -12,6 +12,8 @@ export interface User { enabled: boolean; metadata?: { _reserved: boolean; + _deprecated?: boolean; + _deprecated_reason?: string; }; } diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx index be7517ff892b5..a97781ba25ea6 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx @@ -32,6 +32,14 @@ const createUser = (username: string, roles = ['idk', 'something']) => { }; } + if (username === 'deprecated_user') { + user.metadata = { + _reserved: true, + _deprecated: true, + _deprecated_reason: 'beacuse I said so.', + }; + } + return user; }; @@ -162,6 +170,28 @@ describe('EditUserPage', () => { expectSaveButton(wrapper); }); + it('warns when viewing a depreciated user', async () => { + const user = createUser('deprecated_user'); + const { apiClient, rolesAPIClient } = buildClients(user); + const securitySetup = buildSecuritySetup(); + + const wrapper = mountWithIntl( + + ); + + await waitForRender(wrapper); + expect(apiClient.getUser).toBeCalledTimes(1); + expect(securitySetup.authc.getCurrentUser).toBeCalledTimes(1); + + expect(findTestSubject(wrapper, 'deprecatedUserWarning')).toHaveLength(1); + }); + it('warns when user is assigned a deprecated role', async () => { const user = createUser('existing_user', ['deprecated-role']); const { apiClient, rolesAPIClient } = buildClients(user); diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx index 1c8130029bb50..7172ff178eb6b 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx @@ -38,6 +38,7 @@ import { RolesAPIClient } from '../../roles'; import { ConfirmDeleteUsers, ChangePasswordForm } from '../components'; import { UserValidator, UserValidationResult } from './validate_user'; import { RoleComboBox } from '../../role_combo_box'; +import { isUserDeprecated, getExtendedUserDeprecationNotice, isUserReserved } from '../user_utils'; import { UserAPIClient } from '..'; interface Props { @@ -244,7 +245,7 @@ export class EditUserPage extends Component { return ( - {user.username === 'kibana' ? ( + {user.username === 'kibana' || user.username === 'kibana_system' ? ( {

@@ -372,7 +373,7 @@ export class EditUserPage extends Component { isNewUser, showDeleteConfirmation, } = this.state; - const reserved = user.metadata && user.metadata._reserved; + const reserved = isUserReserved(user); if (!user || !roles) { return null; } @@ -427,15 +428,31 @@ export class EditUserPage extends Component { {reserved && ( - -

- +

+ -

-
+ /> +

+
+ + + )} + + {isUserDeprecated(user) && ( + + + + )} {showDeleteConfirmation ? ( diff --git a/x-pack/plugins/security/public/management/users/user_utils.test.ts b/x-pack/plugins/security/public/management/users/user_utils.test.ts new file mode 100644 index 0000000000000..572b94ab08037 --- /dev/null +++ b/x-pack/plugins/security/public/management/users/user_utils.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { User } from '../../../common/model'; +import { isUserReserved, isUserDeprecated, getExtendedUserDeprecationNotice } from './user_utils'; + +describe('#isUserReserved', () => { + it('returns false for a user with no metadata', () => { + expect(isUserReserved({} as User)).toEqual(false); + }); + + it('returns false for a user with the reserved flag set to false', () => { + expect(isUserReserved({ metadata: { _reserved: false } } as User)).toEqual(false); + }); + + it('returns true for a user with the reserved flag set to true', () => { + expect(isUserReserved({ metadata: { _reserved: true } } as User)).toEqual(true); + }); +}); + +describe('#isUserDeprecated', () => { + it('returns false for a user with no metadata', () => { + expect(isUserDeprecated({} as User)).toEqual(false); + }); + + it('returns false for a user with the deprecated flag set to false', () => { + expect(isUserDeprecated({ metadata: { _deprecated: false } } as User)).toEqual(false); + }); + + it('returns true for a user with the deprecated flag set to true', () => { + expect(isUserDeprecated({ metadata: { _deprecated: true } } as User)).toEqual(true); + }); +}); + +describe('#getExtendedUserDeprecationNotice', () => { + it('returns a notice when no reason is provided', () => { + expect( + getExtendedUserDeprecationNotice({ username: 'test_user' } as User) + ).toMatchInlineSnapshot(`"The test_user user is deprecated. "`); + }); + + it('returns a notice augmented with reason when provided', () => { + expect( + getExtendedUserDeprecationNotice({ + username: 'test_user', + metadata: { _reserved: true, _deprecated_reason: 'some reason' }, + } as User) + ).toMatchInlineSnapshot(`"The test_user user is deprecated. some reason"`); + }); +}); diff --git a/x-pack/plugins/security/public/management/users/user_utils.ts b/x-pack/plugins/security/public/management/users/user_utils.ts index f46f6f897e23b..211aad904d466 100644 --- a/x-pack/plugins/security/public/management/users/user_utils.ts +++ b/x-pack/plugins/security/public/management/users/user_utils.ts @@ -4,6 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { User } from '../../../common/model'; export const isUserReserved = (user: User) => user.metadata?._reserved ?? false; + +export const isUserDeprecated = (user: User) => user.metadata?._deprecated ?? false; + +export const getExtendedUserDeprecationNotice = (user: User) => { + const reason = user.metadata?._deprecated_reason ?? ''; + return i18n.translate('xpack.security.management.users.extendedUserDeprecationNotice', { + defaultMessage: `The {username} user is deprecated. {reason}`, + values: { + username: user.username, + reason, + }, + }); +}; diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx index 031b67d5d9122..d3b85b83ff6a4 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx @@ -102,6 +102,38 @@ describe('UsersGridPage', () => { expect(findTestSubject(wrapper, 'userDisabled')).toHaveLength(1); }); + it('renders deprecated users', async () => { + const apiClientMock = userAPIClientMock.create(); + apiClientMock.getUsers.mockImplementation(() => { + return Promise.resolve([ + { + username: 'foo', + email: 'foo@bar.net', + full_name: 'foo bar', + roles: ['kibana_user'], + enabled: true, + metadata: { + _reserved: true, + _deprecated: true, + _deprecated_reason: 'This user is not cool anymore.', + }, + }, + ]); + }); + + const wrapper = mountWithIntl( + + ); + + await waitForRender(wrapper); + + expect(findTestSubject(wrapper, 'userDeprecated')).toHaveLength(1); + }); + it('renders a warning when a user is assigned a deprecated role', async () => { const apiClientMock = userAPIClientMock.create(); apiClientMock.getUsers.mockImplementation(() => { diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx index 6837fcf430fe7..f8882129772f7 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx @@ -26,8 +26,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { NotificationsStart } from 'src/core/public'; import { User, Role } from '../../../../common/model'; import { ConfirmDeleteUsers } from '../components'; -import { isUserReserved } from '../user_utils'; -import { DisabledBadge, ReservedBadge } from '../../badges'; +import { isUserReserved, getExtendedUserDeprecationNotice, isUserDeprecated } from '../user_utils'; +import { DisabledBadge, ReservedBadge, DeprecatedBadge } from '../../badges'; import { RoleTableDisplay } from '../../role_table_display'; import { RolesAPIClient } from '../../roles'; import { UserAPIClient } from '..'; @@ -360,6 +360,7 @@ export class UsersGridPage extends Component { private getUserStatusBadges = (user: User) => { const enabled = user.enabled; const reserved = isUserReserved(user); + const deprecated = isUserDeprecated(user); const badges = []; if (!enabled) { @@ -378,9 +379,17 @@ export class UsersGridPage extends Component { /> ); } + if (deprecated) { + badges.push( + + ); + } return ( - + {badges.map((badge, index) => ( {badge} diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index 48a73586a6fed..d2d2e82951a3e 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -11,7 +11,7 @@ import { IClusterClient, Headers, } from '../../../../../../src/core/server'; -import { deepFreeze } from '../../../../../../src/core/utils'; +import { deepFreeze } from '../../../../../../src/core/server'; import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 2c24864649977..0e1e2e2afeb13 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -407,7 +407,7 @@ describe('config schema', () => { "basic1": Object { "description": "Log in with Elasticsearch", "enabled": true, - "icon": "logoElastic", + "icon": "logoElasticsearch", "order": 0, "showInSelector": true, }, @@ -467,7 +467,7 @@ describe('config schema', () => { "token1": Object { "description": "Log in with Elasticsearch", "enabled": true, - "icon": "logoElastic", + "icon": "logoElasticsearch", "order": 0, "showInSelector": true, }, @@ -746,14 +746,14 @@ describe('config schema', () => { "basic1": Object { "description": "Log in with Elasticsearch", "enabled": true, - "icon": "logoElastic", + "icon": "logoElasticsearch", "order": 0, "showInSelector": true, }, "basic2": Object { "description": "Log in with Elasticsearch", "enabled": false, - "icon": "logoElastic", + "icon": "logoElasticsearch", "order": 1, "showInSelector": true, }, diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 8fe79a788ac51..695653a2ac1db 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -62,7 +62,7 @@ const providersConfigSchema = schema.object( defaultMessage: 'Log in with Elasticsearch', }), }), - icon: schema.string({ defaultValue: 'logoElastic' }), + icon: schema.string({ defaultValue: 'logoElasticsearch' }), showInSelector: schema.boolean({ defaultValue: true, validate: value => { @@ -78,7 +78,7 @@ const providersConfigSchema = schema.object( defaultMessage: 'Log in with Elasticsearch', }), }), - icon: schema.string({ defaultValue: 'logoElastic' }), + icon: schema.string({ defaultValue: 'logoElasticsearch' }), showInSelector: schema.boolean({ defaultValue: true, validate: value => { diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 97f5aea888dc7..77a2d716e6d87 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -13,7 +13,7 @@ import { Logger, PluginInitializerContext, } from '../../../../src/core/server'; -import { deepFreeze } from '../../../../src/core/utils'; +import { deepFreeze } from '../../../../src/core/server'; import { SpacesPluginSetup } from '../../spaces/server'; import { PluginSetupContract as FeaturesSetupContract } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index 014ad390a3d53..5c41a48bf5ee4 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -252,7 +252,7 @@ describe('Login view routes', () => { name: 'basic1', type: 'basic', usesLoginForm: true, - icon: 'logoElastic', + icon: 'logoElasticsearch', description: 'Log in with Elasticsearch', }, ], @@ -264,7 +264,7 @@ describe('Login view routes', () => { name: 'token1', type: 'token', usesLoginForm: true, - icon: 'logoElastic', + icon: 'logoElasticsearch', description: 'Log in with Elasticsearch', }, ], @@ -309,7 +309,7 @@ describe('Login view routes', () => { name: 'basic1', type: 'basic', usesLoginForm: true, - icon: 'logoElastic', + icon: 'logoElasticsearch', description: 'Log in with Elasticsearch', }, ], @@ -325,7 +325,7 @@ describe('Login view routes', () => { name: 'basic1', type: 'basic', usesLoginForm: true, - icon: 'logoElastic', + icon: 'logoElasticsearch', description: 'Log in with Elasticsearch', }, ], @@ -340,7 +340,7 @@ describe('Login view routes', () => { order: 0, description: 'some-desc1', hint: 'some-hint1', - icon: 'logoElastic', + icon: 'logoElasticsearch', }, }, saml: { @@ -355,7 +355,7 @@ describe('Login view routes', () => { name: 'basic1', description: 'some-desc1', hint: 'some-hint1', - icon: 'logoElastic', + icon: 'logoElasticsearch', usesLoginForm: true, }, { diff --git a/x-pack/plugins/siem/common/types/timeline/index.ts b/x-pack/plugins/siem/common/types/timeline/index.ts index 55b4f9c6aca4d..43f66da6109df 100644 --- a/x-pack/plugins/siem/common/types/timeline/index.ts +++ b/x-pack/plugins/siem/common/types/timeline/index.ts @@ -144,6 +144,11 @@ export const TimelineTypeLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TimelineType.default), ]); +const TimelineTypeLiteralWithNullRt = unionWithNullType(TimelineTypeLiteralRt); + +export type TimelineTypeLiteral = runtimeTypes.TypeOf; +export type TimelineTypeLiteralWithNull = runtimeTypes.TypeOf; + export const SavedTimelineRuntimeType = runtimeTypes.partial({ columns: unionWithNullType(runtimeTypes.array(SavedColumnHeaderRuntimeType)), dataProviders: unionWithNullType(runtimeTypes.array(SavedDataProviderRuntimeType)), diff --git a/x-pack/plugins/siem/cypress/integration/signal_detection_rules_custom.spec.ts b/x-pack/plugins/siem/cypress/integration/signal_detection_rules_custom.spec.ts index b7e42f7e46a70..24325676f0cca 100644 --- a/x-pack/plugins/siem/cypress/integration/signal_detection_rules_custom.spec.ts +++ b/x-pack/plugins/siem/cypress/integration/signal_detection_rules_custom.spec.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { newRule, totalNumberOfPrebuiltRules } from '../objects/rule'; +import { newRule, totalNumberOfPrebuiltRulesInEsArchive } from '../objects/rule'; import { ABOUT_FALSE_POSITIVES, @@ -91,7 +91,7 @@ describe('Signal detection rules, custom', () => { changeToThreeHundredRowsPerPage(); waitForRulesToBeLoaded(); - const expectedNumberOfRules = totalNumberOfPrebuiltRules + 1; + const expectedNumberOfRules = totalNumberOfPrebuiltRulesInEsArchive + 1; cy.get(RULES_TABLE).then($table => { cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); }); diff --git a/x-pack/plugins/siem/cypress/integration/signal_detection_rules_export.spec.ts b/x-pack/plugins/siem/cypress/integration/signal_detection_rules_export.spec.ts new file mode 100644 index 0000000000000..f0e8b4556f704 --- /dev/null +++ b/x-pack/plugins/siem/cypress/integration/signal_detection_rules_export.spec.ts @@ -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 { + goToManageSignalDetectionRules, + waitForSignalsIndexToBeCreated, + waitForSignalsPanelToBeLoaded, +} from '../tasks/detections'; +import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; +import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; +import { exportFirstRule } from '../tasks/signal_detection_rules'; + +import { DETECTIONS } from '../urls/navigation'; + +const EXPECTED_EXPORTED_RULE_FILE_PATH = 'cypress/test_files/expected_rules_export.ndjson'; + +describe('Export rules', () => { + before(() => { + esArchiverLoad('custom_rules'); + cy.server(); + cy.route( + 'POST', + '**api/detection_engine/rules/_export?exclude_export_details=false&file_name=rules_export.ndjson*' + ).as('export'); + }); + + after(() => { + esArchiverUnload('custom_rules'); + }); + + it('Exports a custom rule', () => { + loginAndWaitForPageWithoutDateRange(DETECTIONS); + waitForSignalsPanelToBeLoaded(); + waitForSignalsIndexToBeCreated(); + goToManageSignalDetectionRules(); + exportFirstRule(); + cy.wait('@export').then(xhr => { + cy.readFile(EXPECTED_EXPORTED_RULE_FILE_PATH).then($expectedExportedJson => { + cy.wrap(xhr.responseBody).should('eql', $expectedExportedJson); + }); + }); + }); +}); diff --git a/x-pack/plugins/siem/cypress/integration/signal_detection_rules_ml.spec.ts b/x-pack/plugins/siem/cypress/integration/signal_detection_rules_ml.spec.ts index db56193c6b51c..ad56c905aa4fc 100644 --- a/x-pack/plugins/siem/cypress/integration/signal_detection_rules_ml.spec.ts +++ b/x-pack/plugins/siem/cypress/integration/signal_detection_rules_ml.spec.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { machineLearningRule, totalNumberOfPrebuiltRules } from '../objects/rule'; +import { machineLearningRule, totalNumberOfPrebuiltRulesInEsArchive } from '../objects/rule'; import { ABOUT_FALSE_POSITIVES, @@ -88,7 +88,7 @@ describe('Signal detection rules, machine learning', () => { changeToThreeHundredRowsPerPage(); waitForRulesToBeLoaded(); - const expectedNumberOfRules = totalNumberOfPrebuiltRules + 1; + const expectedNumberOfRules = totalNumberOfPrebuiltRulesInEsArchive + 1; cy.get(RULES_TABLE).then($table => { cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); }); diff --git a/x-pack/plugins/siem/cypress/integration/signal_detection_rules_prebuilt.spec.ts b/x-pack/plugins/siem/cypress/integration/signal_detection_rules_prebuilt.spec.ts index 74a11fb455ac0..866302e81e1a0 100644 --- a/x-pack/plugins/siem/cypress/integration/signal_detection_rules_prebuilt.spec.ts +++ b/x-pack/plugins/siem/cypress/integration/signal_detection_rules_prebuilt.spec.ts @@ -28,12 +28,7 @@ import { waitForSignalsIndexToBeCreated, waitForSignalsPanelToBeLoaded, } from '../tasks/detections'; -import { - esArchiverLoad, - esArchiverLoadEmptyKibana, - esArchiverUnloadEmptyKibana, - esArchiverUnload, -} from '../tasks/es_archiver'; +import { esArchiverLoadEmptyKibana, esArchiverUnloadEmptyKibana } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; import { DETECTIONS } from '../urls/navigation'; @@ -76,15 +71,32 @@ describe('Signal detection rules, prebuilt rules', () => { describe('Deleting prebuilt rules', () => { beforeEach(() => { - esArchiverLoad('prebuilt_rules_loaded'); + const expectedNumberOfRules = totalNumberOfPrebuiltRules; + const expectedElasticRulesBtnText = `Elastic rules (${expectedNumberOfRules})`; + + esArchiverLoadEmptyKibana(); loginAndWaitForPageWithoutDateRange(DETECTIONS); waitForSignalsPanelToBeLoaded(); waitForSignalsIndexToBeCreated(); goToManageSignalDetectionRules(); + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); + loadPrebuiltDetectionRules(); + waitForPrebuiltDetectionRulesToBeLoaded(); + + cy.get(ELASTIC_RULES_BTN) + .invoke('text') + .should('eql', expectedElasticRulesBtnText); + + changeToThreeHundredRowsPerPage(); + waitForRulesToBeLoaded(); + + cy.get(RULES_TABLE).then($table => { + cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + }); }); afterEach(() => { - esArchiverUnload('prebuilt_rules_loaded'); + esArchiverUnloadEmptyKibana(); }); it('Does not allow to delete one rule when more than one is selected', () => { diff --git a/x-pack/plugins/siem/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/siem/cypress/integration/timeline_data_providers.spec.ts index 4889d40ae7d39..08eb3df57c7a0 100644 --- a/x-pack/plugins/siem/cypress/integration/timeline_data_providers.spec.ts +++ b/x-pack/plugins/siem/cypress/integration/timeline_data_providers.spec.ts @@ -49,7 +49,7 @@ describe('timeline data providers', () => { .first() .invoke('text') .should(hostname => { - expect(dataProviderText).to.eq(`host.name: "${hostname}"`); + expect(dataProviderText).to.eq(`host.name: "${hostname}"AND`); }); }); }); diff --git a/x-pack/plugins/siem/cypress/objects/rule.ts b/x-pack/plugins/siem/cypress/objects/rule.ts index 4e0189ea597da..7ce8aa69f3339 100644 --- a/x-pack/plugins/siem/cypress/objects/rule.ts +++ b/x-pack/plugins/siem/cypress/objects/rule.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export const totalNumberOfPrebuiltRules = 127; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { rawRules } from '../../server/lib/detection_engine/rules/prepackaged_rules/index'; + +export const totalNumberOfPrebuiltRules = rawRules.length; + +export const totalNumberOfPrebuiltRulesInEsArchive = 127; interface Mitre { tactic: string; diff --git a/x-pack/plugins/siem/cypress/plugins/index.js b/x-pack/plugins/siem/cypress/plugins/index.js index 1132e66cc16dd..01d31b85de463 100644 --- a/x-pack/plugins/siem/cypress/plugins/index.js +++ b/x-pack/plugins/siem/cypress/plugins/index.js @@ -19,6 +19,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies const wp = require('@cypress/webpack-preprocessor'); + module.exports = on => { const options = { webpackOptions: { diff --git a/x-pack/plugins/siem/cypress/screens/case_details.ts b/x-pack/plugins/siem/cypress/screens/case_details.ts index 3bd180b1d588f..dadc1bdff1933 100644 --- a/x-pack/plugins/siem/cypress/screens/case_details.ts +++ b/x-pack/plugins/siem/cypress/screens/case_details.ts @@ -10,7 +10,7 @@ export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; -export const CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN = '[data-test-subj="push-to-service-now"]'; +export const CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN = '[data-test-subj="push-to-external-service"]'; export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; diff --git a/x-pack/plugins/siem/cypress/screens/signal_detection_rules.ts b/x-pack/plugins/siem/cypress/screens/signal_detection_rules.ts index f74f5c26ddc2e..a41b8296f83e4 100644 --- a/x-pack/plugins/siem/cypress/screens/signal_detection_rules.ts +++ b/x-pack/plugins/siem/cypress/screens/signal_detection_rules.ts @@ -18,6 +18,8 @@ export const DELETE_RULE_BULK_BTN = '[data-test-subj="deleteRuleBulk"]'; export const ELASTIC_RULES_BTN = '[data-test-subj="show-elastic-rules-filter-button"]'; +export const EXPORT_ACTION_BTN = '[data-test-subj="exportRuleAction"]'; + export const FIFTH_RULE = 4; export const FIRST_RULE = 0; diff --git a/x-pack/plugins/siem/cypress/support/index.js b/x-pack/plugins/siem/cypress/support/index.js index 37fa920a8bc31..672acfd41a264 100644 --- a/x-pack/plugins/siem/cypress/support/index.js +++ b/x-pack/plugins/siem/cypress/support/index.js @@ -32,5 +32,10 @@ Cypress.on('uncaught:exception', err => { } }); +Cypress.on('window:before:load', win => { + win.fetch = null; + win.Blob = null; +}); + // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/x-pack/plugins/siem/cypress/tasks/signal_detection_rules.ts b/x-pack/plugins/siem/cypress/tasks/signal_detection_rules.ts index a404f1142cba7..5a4d71de9e851 100644 --- a/x-pack/plugins/siem/cypress/tasks/signal_detection_rules.ts +++ b/x-pack/plugins/siem/cypress/tasks/signal_detection_rules.ts @@ -23,6 +23,7 @@ import { RULES_TABLE, SORT_RULES_BTN, THREE_HUNDRED_ROWS, + EXPORT_ACTION_BTN, } from '../screens/signal_detection_rules'; export const activateRule = (rulePosition: number) => { @@ -48,6 +49,14 @@ export const deleteSelectedRules = () => { cy.get(DELETE_RULE_BULK_BTN).click(); }; +export const exportFirstRule = () => { + cy.get(COLLAPSED_ACTION_BTN) + .first() + .click({ force: true }); + cy.get(EXPORT_ACTION_BTN).click(); + cy.get(EXPORT_ACTION_BTN).should('not.exist'); +}; + export const filterByCustomRules = () => { cy.get(CUSTOM_RULES_BTN).click({ force: true }); cy.get(LOADING_SPINNER).should('exist'); diff --git a/x-pack/plugins/siem/cypress/test_files/expected_rules_export.ndjson b/x-pack/plugins/siem/cypress/test_files/expected_rules_export.ndjson new file mode 100644 index 0000000000000..c2e779feeca77 --- /dev/null +++ b/x-pack/plugins/siem/cypress/test_files/expected_rules_export.ndjson @@ -0,0 +1,2 @@ +{"actions":[],"created_at":"2020-03-26T10:09:07.569Z","updated_at":"2020-03-26T10:09:08.021Z","created_by":"elastic","description":"Rule 1","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","id":"49db5bd1-bdd5-4821-be26-bb70a815dedb","immutable":false,"index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"0cea4194-03f2-4072-b281-d31b72221d9d","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":50,"name":"Rule 1","query":"host.name:*","references":[],"meta":{"from":"1m","throttle":"no_actions"},"severity":"low","updated_by":"elastic","tags":["rule1"],"to":"now","type":"query","threat":[],"throttle":"no_actions","version":1} +{"exported_count":1,"missing_rules":[],"missing_rules_count":0} diff --git a/x-pack/plugins/siem/cypress/tsconfig.json b/x-pack/plugins/siem/cypress/tsconfig.json index b9c1d950eaaef..929a3fb39babb 100644 --- a/x-pack/plugins/siem/cypress/tsconfig.json +++ b/x-pack/plugins/siem/cypress/tsconfig.json @@ -8,6 +8,7 @@ "types": [ "cypress", "node" - ] - } -} \ No newline at end of file + ], + "resolveJsonModule": true, + }, +} diff --git a/x-pack/plugins/siem/package.json b/x-pack/plugins/siem/package.json index 829332918d3c4..a055d011a5cbb 100644 --- a/x-pack/plugins/siem/package.json +++ b/x-pack/plugins/siem/package.json @@ -8,7 +8,7 @@ "extract-mitre-attacks": "node scripts/extract_tactics_techniques_mitre.js && node ../../../scripts/eslint ./public/pages/detection_engine/mitre/mitre_tactics_techniques.ts --fix", "build-graphql-types": "node scripts/generate_types_from_graphql.js", "cypress:open": "cypress open --config-file ./cypress/cypress.json", - "cypress:run": "cypress run --spec ./cypress/integration/**/*.spec.ts --config-file ./cypress/cypress.json --reporter ../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json; status=$?; ../../node_modules/.bin/mochawesome-merge --reportDir ../../../target/kibana-siem/cypress/results > ../../../target/kibana-siem/cypress/results/output.json; ../../../node_modules/.bin/marge ../../../target/kibana-siem/cypress/results/output.json --reportDir ../../../target/kibana-siem/cypress/results; mkdir -p ../../../target/junit && cp ../../../target/kibana-siem/cypress/results/*.xml ../../../target/junit/ && exit $status;", + "cypress:run": "cypress run --browser chrome --headless --spec ./cypress/integration/**/*.spec.ts --config-file ./cypress/cypress.json --reporter ../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json; status=$?; ../../node_modules/.bin/mochawesome-merge --reportDir ../../../target/kibana-siem/cypress/results > ../../../target/kibana-siem/cypress/results/output.json; ../../../node_modules/.bin/marge ../../../target/kibana-siem/cypress/results/output.json --reportDir ../../../target/kibana-siem/cypress/results; mkdir -p ../../../target/junit && cp ../../../target/kibana-siem/cypress/results/*.xml ../../../target/junit/ && exit $status;", "cypress:run-as-ci": "node ../../../scripts/functional_tests --config ../../test/siem_cypress/config.ts" }, "devDependencies": { diff --git a/x-pack/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx index 248ae671550ef..8e6743ad8f92e 100644 --- a/x-pack/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -12,9 +12,15 @@ import { Dispatch } from 'redux'; import { BeforeCapture } from './drag_drop_context'; import { BrowserFields } from '../../containers/source'; -import { dragAndDropModel, dragAndDropSelectors } from '../../store'; +import { dragAndDropModel, dragAndDropSelectors, timelineSelectors } from '../../store'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; import { State } from '../../store/reducer'; +import { DataProvider } from '../timeline/data_providers/data_provider'; +import { reArrangeProviders } from '../timeline/data_providers/helpers'; +import { ACTIVE_TIMELINE_REDUX_ID } from '../top_n'; +import { ADDED_TO_TIMELINE_MESSAGE } from '../../hooks/translations'; +import { useAddToTimelineSensor } from '../../hooks/use_add_to_timeline'; +import { displaySuccessToast, useStateToaster } from '../toasters'; import { addFieldToTimelineColumns, @@ -23,8 +29,8 @@ import { IS_DRAGGING_CLASS_NAME, IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME, providerWasDroppedOnTimeline, - providerWasDroppedOnTimelineButton, draggableIsField, + userIsReArrangingProviders, } from './helpers'; // @ts-ignore @@ -37,58 +43,92 @@ interface Props { } interface OnDragEndHandlerParams { + activeTimelineDataProviders: DataProvider[]; browserFields: BrowserFields; dataProviders: IdToDataProvider; dispatch: Dispatch; + onAddedToTimeline: (fieldOrValue: string) => void; result: DropResult; } const onDragEndHandler = ({ + activeTimelineDataProviders, browserFields, dataProviders, dispatch, + onAddedToTimeline, result, }: OnDragEndHandlerParams) => { - if (providerWasDroppedOnTimeline(result)) { - addProviderToTimeline({ dataProviders, result, dispatch }); - } else if (providerWasDroppedOnTimelineButton(result)) { - addProviderToTimeline({ dataProviders, result, dispatch }); + if (userIsReArrangingProviders(result)) { + reArrangeProviders({ + dataProviders: activeTimelineDataProviders, + destination: result.destination, + dispatch, + source: result.source, + timelineId: ACTIVE_TIMELINE_REDUX_ID, + }); + } else if (providerWasDroppedOnTimeline(result)) { + addProviderToTimeline({ + activeTimelineDataProviders, + dataProviders, + dispatch, + onAddedToTimeline, + result, + timelineId: ACTIVE_TIMELINE_REDUX_ID, + }); } else if (fieldWasDroppedOnTimelineColumns(result)) { - addFieldToTimelineColumns({ browserFields, dispatch, result }); + addFieldToTimelineColumns({ + browserFields, + dispatch, + result, + timelineId: ACTIVE_TIMELINE_REDUX_ID, + }); } }; +const sensors = [useAddToTimelineSensor]; + /** * DragDropContextWrapperComponent handles all drag end events */ export const DragDropContextWrapperComponent = React.memo( - ({ browserFields, children, dataProviders, dispatch }) => { + ({ activeTimelineDataProviders, browserFields, children, dataProviders, dispatch }) => { + const [, dispatchToaster] = useStateToaster(); + const onAddedToTimeline = useCallback( + (fieldOrValue: string) => { + displaySuccessToast(ADDED_TO_TIMELINE_MESSAGE(fieldOrValue), dispatchToaster); + }, + [dispatchToaster] + ); + const onDragEnd = useCallback( (result: DropResult) => { - enableScrolling(); - - if (dataProviders != null) { - onDragEndHandler({ - browserFields, - result, - dataProviders, - dispatch, - }); - } - - if (!draggableIsField(result)) { + try { + enableScrolling(); + + if (dataProviders != null) { + onDragEndHandler({ + activeTimelineDataProviders, + browserFields, + dataProviders, + dispatch, + onAddedToTimeline, + result, + }); + } + } finally { document.body.classList.remove(IS_DRAGGING_CLASS_NAME); - } - if (draggableIsField(result)) { - document.body.classList.remove(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME); + if (draggableIsField(result)) { + document.body.classList.remove(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME); + } } }, - [browserFields, dataProviders] + [dataProviders, activeTimelineDataProviders, browserFields] ); return ( // @ts-ignore - + {children} ); @@ -96,7 +136,8 @@ export const DragDropContextWrapperComponent = React.memo { return ( prevProps.children === nextProps.children && - prevProps.dataProviders === nextProps.dataProviders + prevProps.dataProviders === nextProps.dataProviders && + prevProps.activeTimelineDataProviders === nextProps.activeTimelineDataProviders ); // prevent re-renders when data providers are added or removed, but all other props are the same } ); @@ -104,11 +145,15 @@ export const DragDropContextWrapperComponent = React.memo { + const activeTimelineDataProviders = + timelineSelectors.getTimelineByIdSelector()(state, ACTIVE_TIMELINE_REDUX_ID)?.dataProviders ?? + emptyActiveTimelineDataProviders; const dataProviders = dragAndDropSelectors.dataProvidersSelector(state) ?? emptyDataProviders; - return { dataProviders }; + return { activeTimelineDataProviders, dataProviders }; }; const connector = connect(mapStateToProps); diff --git a/x-pack/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx index c7da5b5c58951..5676c8fe5c30b 100644 --- a/x-pack/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx @@ -132,6 +132,7 @@ export const DraggableWrapper = React.memo( const hoverContent = useMemo( () => ( void; showTopN: boolean; @@ -26,12 +29,14 @@ interface Props { } const DraggableWrapperHoverContentComponent: React.FC = ({ + draggableId, field, onFilterAdded, showTopN, toggleTopN, value, }) => { + const startDragToTimeline = useAddToTimeline({ draggableId, fieldName: field }); const kibana = useKibana(); const { filterManager: timelineFilterManager } = useTimelineContext(); const filterManager = useMemo(() => kibana.services.data.query.filterManager, [ @@ -92,6 +97,18 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ )} + {!showTopN && value != null && draggableId != null && ( + + + + )} + {({ browserFields }) => ( <> diff --git a/x-pack/plugins/siem/public/components/drag_and_drop/helpers.test.ts b/x-pack/plugins/siem/public/components/drag_and_drop/helpers.test.ts index 753fa5b54eade..333875feae4d1 100644 --- a/x-pack/plugins/siem/public/components/drag_and_drop/helpers.test.ts +++ b/x-pack/plugins/siem/public/components/drag_and_drop/helpers.test.ts @@ -28,7 +28,6 @@ import { getDroppableId, getFieldIdFromDraggable, getProviderIdFromDraggable, - getTimelineIdFromDestination, providerWasDroppedOnTimeline, reasonIsDrop, sourceIsContent, @@ -381,100 +380,6 @@ describe('helpers', () => { }); }); - describe('#getTimelineIdFromDestination', () => { - test('it returns returns the timeline id from the destination when it is a provider', () => { - expect( - getTimelineIdFromDestination({ - destination: { - droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS, - index: 0, - }, - draggableId: getDraggableId('685260508808089'), - reason: 'DROP', - source: { - droppableId: getDroppableId('685260508808089'), - index: 0, - }, - type: 'DEFAULT', - mode: 'FLUID', - }) - ).toEqual('timeline'); - }); - - test('it returns returns the timeline id from the destination when the destination is timeline columns', () => { - expect( - getTimelineIdFromDestination({ - destination: { - droppableId: DROPPABLE_ID_TIMELINE_COLUMNS, - index: 0, - }, - draggableId: getDraggableFieldId({ contextId: 'test', fieldId: 'event.action' }), - reason: 'DROP', - source: { - droppableId: 'fake.source.droppable.id', - index: 0, - }, - type: 'DEFAULT', - mode: 'FLUID', - }) - ).toEqual('timeline-1'); - }); - - test('it returns returns the timeline id from the destination when it is a button', () => { - expect( - getTimelineIdFromDestination({ - destination: { - droppableId: `${droppableTimelineFlyoutButtonPrefix}timeline`, - index: 0, - }, - draggableId: getDraggableId('685260508808089'), - reason: 'DROP', - source: { - droppableId: getDroppableId('685260508808089'), - index: 0, - }, - type: 'DEFAULT', - mode: 'FLUID', - }) - ).toEqual('timeline'); - }); - - test('it returns returns an empty string when the destination is undefined', () => { - expect( - getTimelineIdFromDestination({ - destination: undefined, - draggableId: `${draggableIdPrefix}.timeline.timeline.dataProvider.685260508808089`, - reason: 'DROP', - source: { - droppableId: `${droppableIdPrefix}.timelineProviders.timeline`, - index: 0, - }, - type: 'DEFAULT', - mode: 'FLUID', - }) - ).toEqual(''); - }); - - test('it returns returns an empty string when the destination is not a timeline', () => { - expect( - getTimelineIdFromDestination({ - destination: { - droppableId: `${droppableIdPrefix}.somewhere.else`, - index: 0, - }, - draggableId: getDraggableId('685260508808089'), - reason: 'DROP', - source: { - droppableId: getDroppableId('685260508808089'), - index: 0, - }, - type: 'DEFAULT', - mode: 'FLUID', - }) - ).toEqual(''); - }); - }); - describe('#getProviderIdFromDraggable', () => { test('it returns the expected id', () => { const id = getProviderIdFromDraggable({ diff --git a/x-pack/plugins/siem/public/components/drag_and_drop/helpers.ts b/x-pack/plugins/siem/public/components/drag_and_drop/helpers.ts index cd3d7cc68d537..9b37387ce076b 100644 --- a/x-pack/plugins/siem/public/components/drag_and_drop/helpers.ts +++ b/x-pack/plugins/siem/public/components/drag_and_drop/helpers.ts @@ -10,12 +10,12 @@ import { Dispatch } from 'redux'; import { ActionCreator } from 'typescript-fsa'; import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source'; +import { dragAndDropActions, timelineActions } from '../../store/actions'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; import { ColumnHeaderOptions } from '../../store/timeline/model'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; - import { DataProvider } from '../timeline/data_providers/data_provider'; -import { dragAndDropActions, timelineActions } from '../../store/actions'; +import { addContentToTimeline } from '../timeline/data_providers/helpers'; export const draggableIdPrefix = 'draggableId'; @@ -23,6 +23,8 @@ export const droppableIdPrefix = 'droppableId'; export const draggableContentPrefix = `${draggableIdPrefix}.content.`; +export const draggableTimelineProvidersPrefix = `${draggableIdPrefix}.timelineProviders.`; + export const draggableFieldPrefix = `${draggableIdPrefix}.field.`; export const droppableContentPrefix = `${droppableIdPrefix}.content.`; @@ -46,12 +48,43 @@ export const getDraggableFieldId = ({ fieldId: string; }): string => `${draggableFieldPrefix}${escapeContextId(contextId)}.${escapeFieldId(fieldId)}`; +export const getTimelineProviderDroppableId = ({ + groupIndex, + timelineId, +}: { + groupIndex: number; + timelineId: string; +}): string => `${droppableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}`; + +export const getTimelineProviderDraggableId = ({ + dataProviderId, + groupIndex, + timelineId, +}: { + dataProviderId: string; + groupIndex: number; + timelineId: string; +}): string => + `${draggableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}.${dataProviderId}`; + export const getDroppableId = (visualizationPlaceholderId: string): string => `${droppableContentPrefix}${visualizationPlaceholderId}`; export const sourceIsContent = (result: DropResult): boolean => result.source.droppableId.startsWith(droppableContentPrefix); +export const sourceAndDestinationAreSameTimelineProviders = (result: DropResult): boolean => { + const regex = /^droppableId\.timelineProviders\.(\S+)\./; + const sourceMatches = result.source.droppableId.match(regex) ?? []; + const destinationMatches = result.destination?.droppableId.match(regex) ?? []; + + return ( + sourceMatches.length >= 2 && + destinationMatches.length >= 2 && + sourceMatches[1] === destinationMatches[1] + ); +}; + export const draggableIsContent = (result: DropResult | { draggableId: string }): boolean => result.draggableId.startsWith(draggableContentPrefix); @@ -72,14 +105,6 @@ export const destinationIsTimelineButton = (result: DropResult): boolean => result.destination != null && result.destination.droppableId.startsWith(droppableTimelineFlyoutButtonPrefix); -export const getTimelineIdFromDestination = (result: DropResult): string => - result.destination != null && - (destinationIsTimelineProviders(result) || - destinationIsTimelineButton(result) || - destinationIsTimelineColumns(result)) - ? result.destination.droppableId.substring(result.destination.droppableId.lastIndexOf('.') + 1) - : ''; - export const getProviderIdFromDraggable = (result: DropResult): string => result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1); @@ -100,26 +125,22 @@ export const providerWasDroppedOnTimeline = (result: DropResult): boolean => sourceIsContent(result) && destinationIsTimelineProviders(result); +export const userIsReArrangingProviders = (result: DropResult): boolean => + reasonIsDrop(result) && sourceAndDestinationAreSameTimelineProviders(result); + export const fieldWasDroppedOnTimelineColumns = (result: DropResult): boolean => reasonIsDrop(result) && draggableIsField(result) && destinationIsTimelineColumns(result); -export const providerWasDroppedOnTimelineButton = (result: DropResult): boolean => - reasonIsDrop(result) && - draggableIsContent(result) && - sourceIsContent(result) && - destinationIsTimelineButton(result); - interface AddProviderToTimelineParams { + activeTimelineDataProviders: DataProvider[]; dataProviders: IdToDataProvider; - result: DropResult; dispatch: Dispatch; - addProvider?: ActionCreator<{ - id: string; - provider: DataProvider; - }>; noProviderFound?: ActionCreator<{ id: string; }>; + onAddedToTimeline: (fieldOrValue: string) => void; + result: DropResult; + timelineId: string; } interface AddFieldToTimelineColumnsParams { @@ -131,21 +152,30 @@ interface AddFieldToTimelineColumnsParams { browserFields: BrowserFields; dispatch: Dispatch; result: DropResult; + timelineId: string; } export const addProviderToTimeline = ({ + activeTimelineDataProviders, dataProviders, - result, dispatch, - addProvider = timelineActions.addProvider, + result, + timelineId, noProviderFound = dragAndDropActions.noProviderFound, + onAddedToTimeline, }: AddProviderToTimelineParams): void => { - const timeline = getTimelineIdFromDestination(result); const providerId = getProviderIdFromDraggable(result); - const provider = dataProviders[providerId]; - - if (provider) { - dispatch(addProvider({ id: timeline, provider })); + const providerToAdd = dataProviders[providerId]; + + if (providerToAdd) { + addContentToTimeline({ + dataProviders: activeTimelineDataProviders, + destination: result.destination, + dispatch, + onAddedToTimeline, + providerToAdd, + timelineId, + }); } else { dispatch(noProviderFound({ id: providerId })); } @@ -156,8 +186,8 @@ export const addFieldToTimelineColumns = ({ browserFields, dispatch, result, + timelineId, }: AddFieldToTimelineColumnsParams): void => { - const timeline = getTimelineIdFromDestination(result); const fieldId = getFieldIdFromDraggable(result); const allColumns = getAllFieldsByName(browserFields); const column = allColumns[fieldId]; @@ -175,7 +205,7 @@ export const addFieldToTimelineColumns = ({ aggregatable: column.aggregatable, width: DEFAULT_COLUMN_MIN_WIDTH, }, - id: timeline, + id: timelineId, index: result.destination != null ? result.destination.index : 0, }) ); @@ -188,34 +218,13 @@ export const addFieldToTimelineColumns = ({ id: fieldId, width: DEFAULT_COLUMN_MIN_WIDTH, }, - id: timeline, + id: timelineId, index: result.destination != null ? result.destination.index : 0, }) ); } }; -interface ShowTimelineParams { - result: DropResult; - show: boolean; - dispatch: Dispatch; - showTimeline?: ActionCreator<{ - id: string; - show: boolean; - }>; -} - -export const updateShowTimeline = ({ - result, - show, - dispatch, - showTimeline = timelineActions.showTimeline, -}: ShowTimelineParams): void => { - const timeline = getTimelineIdFromDestination(result); - - dispatch(showTimeline({ id: timeline, show })); -}; - /** * Prevents fields from being dragged or dropped to any area other than column * header drop zone in the timeline diff --git a/x-pack/plugins/siem/public/components/drag_and_drop/translations.ts b/x-pack/plugins/siem/public/components/drag_and_drop/translations.ts index 61d036635a250..cbcb34bd9f75e 100644 --- a/x-pack/plugins/siem/public/components/drag_and_drop/translations.ts +++ b/x-pack/plugins/siem/public/components/drag_and_drop/translations.ts @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; +export const ADD_TO_TIMELINE = i18n.translate('xpack.siem.dragAndDrop.addToTimeline', { + defaultMessage: 'Add to timeline investigation', +}); + export const COPY_TO_CLIPBOARD = i18n.translate('xpack.siem.dragAndDrop.copyToClipboardTooltip', { defaultMessage: 'Copy to Clipboard', }); diff --git a/x-pack/plugins/siem/public/components/flyout/button/index.tsx b/x-pack/plugins/siem/public/components/flyout/button/index.tsx index 6ec5912872467..d0debbca4dec3 100644 --- a/x-pack/plugins/siem/public/components/flyout/button/index.tsx +++ b/x-pack/plugins/siem/public/components/flyout/button/index.tsx @@ -4,67 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiNotificationBadge, EuiIcon, EuiButton } from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import { EuiButton, EuiNotificationBadge, EuiPanel } from '@elastic/eui'; import { rgba } from 'polished'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { DroppableWrapper } from '../../drag_and_drop/droppable_wrapper'; -import { - droppableTimelineFlyoutButtonPrefix, - IS_DRAGGING_CLASS_NAME, -} from '../../drag_and_drop/helpers'; +import { WithSource } from '../../../containers/source'; +import { IS_DRAGGING_CLASS_NAME } from '../../drag_and_drop/helpers'; +import { DataProviders } from '../../timeline/data_providers'; import { DataProvider } from '../../timeline/data_providers/data_provider'; +import { flattenIntoAndGroups } from '../../timeline/data_providers/helpers'; import * as i18n from './translations'; -export const NOT_READY_TO_DROP_CLASS_NAME = 'not-ready-to-drop'; -export const READY_TO_DROP_CLASS_NAME = 'ready-to-drop'; +export const FLYOUT_BUTTON_CLASS_NAME = 'timeline-flyout-button'; + +export const getBadgeCount = (dataProviders: DataProvider[]): number => + flattenIntoAndGroups(dataProviders).reduce((total, group) => total + group.length, 0); + +const SHOW_HIDE_TRANSLATE_X = 497; // px const Container = styled.div` - overflow-x: auto; - overflow-y: hidden; padding-top: 8px; position: fixed; + right: 0px; top: 40%; - right: -51px; - z-index: ${({ theme }) => theme.eui.euiZLevel9}; - transform: rotate(-90deg); + transform: translateX(${SHOW_HIDE_TRANSLATE_X}px); user-select: none; + width: 500px; + z-index: ${({ theme }) => theme.eui.euiZLevel9}; - button { - border-radius: 4px 4px 0 0; - box-shadow: none; - height: 46px; - margin: 1px 0 1px 1px; - width: 136px; - } - - .euiButton:hover:not(:disabled) { + .${IS_DRAGGING_CLASS_NAME} & { transform: none; } - .euiButton--primary:enabled { - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; - box-shadow: none; - } - - .euiButton--primary:enabled:hover, - .euiButton--primary:enabled:focus { - animation: none; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; + .${FLYOUT_BUTTON_CLASS_NAME} { + border-radius: 4px 4px 0 0; box-shadow: none; + height: 46px; } - .${IS_DRAGGING_CLASS_NAME} & .${NOT_READY_TO_DROP_CLASS_NAME} { - color: ${({ theme }) => theme.eui.euiColorSuccess}; - background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.1)} !important; - border: 1px solid ${({ theme }) => theme.eui.euiColorSuccess}; - border-bottom: none; - text-decoration: none; - } - - .${READY_TO_DROP_CLASS_NAME} { + .${IS_DRAGGING_CLASS_NAME} & .${FLYOUT_BUTTON_CLASS_NAME} { color: ${({ theme }) => theme.eui.euiColorSuccess}; background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important; border: 1px solid ${({ theme }) => theme.eui.euiColorSuccess}; @@ -79,10 +60,21 @@ const BadgeButtonContainer = styled.div` align-items: flex-start; display: flex; flex-direction: row; + left: -87px; + position: absolute; + top: 34px; + transform: rotate(-90deg); `; BadgeButtonContainer.displayName = 'BadgeButtonContainer'; +const DataProvidersPanel = styled(EuiPanel)` + border-radius: 0; + padding: 0 4px 0 4px; + user-select: none; + z-index: ${({ theme }) => theme.eui.euiZLevel9}; +`; + interface FlyoutButtonProps { dataProviders: DataProvider[]; onOpen: () => void; @@ -91,57 +83,63 @@ interface FlyoutButtonProps { } export const FlyoutButton = React.memo( - ({ onOpen, show, dataProviders, timelineId }) => - show ? ( - - ( - - {!isDraggingOver ? ( - - {i18n.FLYOUT_BUTTON} - - ) : ( - - - - )} - - - {dataProviders.length} - - - )} - /> + ({ onOpen, show, dataProviders, timelineId }) => { + const badgeCount = useMemo(() => getBadgeCount(dataProviders), [dataProviders]); + + if (!show) { + return null; + } + + return ( + + + + {i18n.FLYOUT_BUTTON} + + + {badgeCount} + + + + + {({ browserFields }) => ( + + )} + + - ) : null, + ); + }, (prevProps, nextProps) => prevProps.show === nextProps.show && prevProps.dataProviders === nextProps.dataProviders && diff --git a/x-pack/plugins/siem/public/components/open_timeline/helpers.test.ts b/x-pack/plugins/siem/public/components/open_timeline/helpers.test.ts index a779d579bf4d1..a7c0b08fc8a21 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/siem/public/components/open_timeline/helpers.test.ts @@ -36,6 +36,7 @@ import { KueryFilterQueryKind } from '../../store/model'; import { Note } from '../../lib/note'; import moment from 'moment'; import sinon from 'sinon'; +import { TimelineType } from '../../../common/types/timeline'; jest.mock('../../store/inputs/actions'); jest.mock('../../store/timeline/actions'); @@ -299,6 +300,9 @@ describe('helpers', () => { sortDirection: 'desc', }, title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, version: '1', width: 1100, }); @@ -393,6 +397,9 @@ describe('helpers', () => { sortDirection: 'desc', }, title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, version: '1', width: 1100, }); @@ -467,6 +474,9 @@ describe('helpers', () => { }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, noteIds: [], pinnedEventIds: {}, pinnedEventsSaveObject: {}, @@ -632,6 +642,9 @@ describe('helpers', () => { }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, noteIds: [], pinnedEventIds: {}, pinnedEventsSaveObject: {}, diff --git a/x-pack/plugins/siem/public/components/open_timeline/helpers.ts b/x-pack/plugins/siem/public/components/open_timeline/helpers.ts index 16ba2de872bd1..681d39feb09f8 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/helpers.ts +++ b/x-pack/plugins/siem/public/components/open_timeline/helpers.ts @@ -8,8 +8,8 @@ import ApolloClient from 'apollo-client'; import { getOr, set, isEmpty } from 'lodash/fp'; import { Action } from 'typescript-fsa'; import uuid from 'uuid'; - import { Dispatch } from 'redux'; + import { oneTimelineQuery } from '../../containers/timeline/one/index.gql_query'; import { TimelineResult, GetOneTimeline, NoteResult } from '../../graphql/types'; import { @@ -169,6 +169,8 @@ export const defaultTimelineToTimelineModel = ( savedObjectId: duplicate ? null : timeline.savedObjectId, version: duplicate ? null : timeline.version, title: duplicate ? '' : timeline.title || '', + templateTimelineId: duplicate ? null : timeline.templateTimelineId, + templateTimelineVersion: duplicate ? null : timeline.templateTimelineVersion, }).reduce((acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc), { ...timelineDefaults, id: '', diff --git a/x-pack/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap index a8ba787477797..3854fc6b985ac 100644 --- a/x-pack/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -722,8 +722,6 @@ exports[`Timeline rendering renders correctly against snapshot 1`] = ` "title": "filebeat-*,auditbeat-*,packetbeat-*", } } - onChangeDataProviderKqlQuery={[MockFunction]} - onChangeDroppableAndProvider={[MockFunction]} onDataProviderEdited={[MockFunction]} onDataProviderRemoved={[MockFunction]} onToggleDataProviderEnabled={[MockFunction]} diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/__snapshots__/empty.test.tsx.snap b/x-pack/plugins/siem/public/components/timeline/data_providers/__snapshots__/empty.test.tsx.snap index 5b4405b8d3bc7..dac95c302af27 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/__snapshots__/empty.test.tsx.snap +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/__snapshots__/empty.test.tsx.snap @@ -14,7 +14,9 @@ exports[`Empty rendering renders correctly against snapshot 1`] = ` Drop anything - + highlighted diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap b/x-pack/plugins/siem/public/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap index b344381f99d4f..330306b00e7c1 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap @@ -1,514 +1,534 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Providers rendering renders correctly against snapshot 1`] = ` - - - + + + + + + + + + ( + + + + + + + + + + ) + + + + + + + + + + + + ( + + + + + + + + + + ) + + + + + + + + + + + + ( + + + + + + + + + + ) + + + + + + + + + + + + ( + + + + + + + + + + ) + + + + + + + + + + + + ( + + + + + + + + + + ) + + + + + + + + + + + + ( + + - - - - - - - - - - - + + + + + ) + + + + + + - - - - - - - - - - + + + + + ( + + + + - - - - - - - - - - + + + + + ) + + + + + + - - - - - - - - - - + + + + + ( + + + + + + + + + + ) + + + + + + - - - - - - - - - - + + + + + ( + + + + - - - - - - - - - - + + + + + ) + + + + + + - - - - - - - - - - + + + + + ( + + + + - - - - - - - - - - + + + + + ) + + + + + + - - - - - - - - - - + + + + + ( + + + + - - - - - - - - - - - - - - Drop here - - to build an - - OR - - query - - - + + + + + + ) + + + + `; diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/data_providers.test.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/data_providers.test.tsx index a88062d9093d7..b77d37e8e31ab 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/data_providers.test.tsx +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/data_providers.test.tsx @@ -26,8 +26,6 @@ describe('DataProviders', () => { browserFields={{}} id="foo" dataProviders={mockDataProviders} - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -47,29 +45,6 @@ describe('DataProviders', () => { browserFields={{}} id="foo" dataProviders={dataProviders} - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} - onDataProviderEdited={jest.fn()} - onDataProviderRemoved={jest.fn()} - onToggleDataProviderEnabled={jest.fn()} - onToggleDataProviderExcluded={jest.fn()} - show={true} - /> - - ); - - dropMessage.forEach(word => expect(wrapper.text()).toContain(word)); - }); - - test('it should STILL render a placeholder given a non-empty collection of data providers', () => { - const wrapper = mount( - - { browserFields={{}} id="foo" dataProviders={mockDataProviders} - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/empty.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/empty.tsx index 60c868f780ff3..1c225eba20b4f 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/empty.tsx +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/empty.tsx @@ -12,6 +12,8 @@ import { AndOrBadge } from '../../and_or_badge'; import * as i18n from './translations'; +export const HIGHLIGHTED_DROP_TARGET_CLASS_NAME = 'highlighted-drop-target'; + const Text = styled(EuiText)` overflow: hidden; margin: 5px 0 5px 0; @@ -88,7 +90,9 @@ export const Empty = React.memo(({ showSmallMsg = false }) => ( {i18n.DROP_ANYTHING} - {i18n.HIGHLIGHTED} + + {i18n.HIGHLIGHTED} + diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/helpers.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/helpers.tsx new file mode 100644 index 0000000000000..8b10ee550096f --- /dev/null +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/helpers.tsx @@ -0,0 +1,334 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { omit } from 'lodash/fp'; +import { DraggableLocation } from 'react-beautiful-dnd'; +import { Dispatch } from 'redux'; + +import { updateProviders } from '../../../store/timeline/actions'; + +import { DataProvider, DataProvidersAnd } from './data_provider'; + +export const omitAnd = (provider: DataProvider): DataProvidersAnd => omit('and', provider); + +export const reorder = ( + group: DataProvidersAnd[], + startIndex: number, + endIndex: number +): DataProvidersAnd[] => { + const groupClone = [...group]; + const [removed] = groupClone.splice(startIndex, 1); // ⚠️ mutation + groupClone.splice(endIndex, 0, removed); // ⚠️ mutation + + return groupClone; +}; + +export const move = ({ + destinationGroup, + moveProviderFromSourceIndex, + moveProviderToDestinationIndex, + sourceGroup, +}: { + destinationGroup: DataProvidersAnd[]; + moveProviderFromSourceIndex: number; + moveProviderToDestinationIndex: number; + sourceGroup: DataProvidersAnd[]; +}): { + updatedDestinationGroup: DataProvidersAnd[]; + updatedSourceGroup: DataProvidersAnd[]; +} => { + const sourceClone = [...sourceGroup]; + const destinationClone = [...destinationGroup]; + + const [removed] = sourceClone.splice(moveProviderFromSourceIndex, 1); // ⚠️ mutation + destinationClone.splice(moveProviderToDestinationIndex, 0, removed); // ⚠️ mutation + + const deDuplicatedDestinationGroup = destinationClone.filter((provider, i) => + provider.id === removed.id && i !== moveProviderToDestinationIndex ? false : true + ); + + return { + updatedDestinationGroup: deDuplicatedDestinationGroup, + updatedSourceGroup: sourceClone, + }; +}; + +export const isValidDestination = ( + destination: DraggableLocation | undefined +): destination is DraggableLocation => destination != null; + +export const sourceAndDestinationAreSameDroppable = ({ + destination, + source, +}: { + destination: DraggableLocation; + source: DraggableLocation; +}): boolean => source.droppableId === destination.droppableId; + +export const flattenIntoAndGroups = (dataProviders: DataProvider[]): DataProvidersAnd[][] => + dataProviders.reduce( + (acc, provider) => [...acc, [omitAnd(provider), ...provider.and]], + [] + ); + +export const reArrangeProvidersInSameGroup = ({ + dataProviderGroups, + destination, + dispatch, + source, + timelineId, +}: { + dataProviderGroups: DataProvidersAnd[][]; + destination: DraggableLocation; + dispatch: Dispatch; + source: DraggableLocation; + timelineId: string; +}) => { + const groupIndex = getGroupIndexFromDroppableId(source.droppableId); + + if ( + indexIsValid({ + index: groupIndex, + dataProviderGroups, + }) + ) { + const reorderedGroup = reorder(dataProviderGroups[groupIndex], source.index, destination.index); + + const updatedGroups = dataProviderGroups.reduce( + (acc, group, i) => [...acc, i === groupIndex ? [...reorderedGroup] : [...group]], + [] + ); + + dispatch( + updateProviders({ + id: timelineId, + providers: unFlattenGroups(updatedGroups.filter(g => g.length)), + }) + ); + } +}; + +export const getGroupIndexFromDroppableId = (droppableId: string): number => + Number(droppableId.substring(droppableId.lastIndexOf('.') + 1)); + +export const indexIsValid = ({ + index, + dataProviderGroups, +}: { + index: number; + dataProviderGroups: DataProvidersAnd[][]; +}): boolean => index >= 0 && index < dataProviderGroups.length; + +export const convertDataProviderAnd = (dataProvidersAnd: DataProvidersAnd): DataProvider => ({ + ...dataProvidersAnd, + and: [], +}); + +export const unFlattenGroups = (groups: DataProvidersAnd[][]): DataProvider[] => + groups.reduce((acc, group) => [...acc, { ...group[0], and: group.slice(1) }], []); + +export const moveProvidersBetweenGroups = ({ + dataProviderGroups, + destination, + dispatch, + source, + timelineId, +}: { + dataProviderGroups: DataProvidersAnd[][]; + destination: DraggableLocation; + dispatch: Dispatch; + source: DraggableLocation; + timelineId: string; +}) => { + const sourceGroupIndex = getGroupIndexFromDroppableId(source.droppableId); + const destinationGroupIndex = getGroupIndexFromDroppableId(destination.droppableId); + + if ( + indexIsValid({ + index: sourceGroupIndex, + dataProviderGroups, + }) && + indexIsValid({ + index: destinationGroupIndex, + dataProviderGroups, + }) + ) { + const sourceGroup = dataProviderGroups[sourceGroupIndex]; + const destinationGroup = dataProviderGroups[destinationGroupIndex]; + const moveProviderFromSourceIndex = source.index; + const moveProviderToDestinationIndex = destination.index; + + const { updatedDestinationGroup, updatedSourceGroup } = move({ + destinationGroup, + moveProviderFromSourceIndex, + moveProviderToDestinationIndex, + sourceGroup, + }); + + const updatedGroups = dataProviderGroups.reduce( + (acc, group, i) => [ + ...acc, + i === sourceGroupIndex + ? [...updatedSourceGroup] + : i === destinationGroupIndex + ? [...updatedDestinationGroup] + : [...group], + ], + [] + ); + + dispatch( + updateProviders({ + id: timelineId, + providers: unFlattenGroups(updatedGroups.filter(g => g.length)), + }) + ); + } +}; + +export const addProviderToEmptyTimeline = ({ + dispatch, + onAddedToTimeline, + providerToAdd, + timelineId, +}: { + dispatch: Dispatch; + onAddedToTimeline: (fieldOrValue: string) => void; + providerToAdd: DataProvider; + timelineId: string; +}) => { + dispatch( + updateProviders({ + id: timelineId, + providers: [providerToAdd], + }) + ); + + onAddedToTimeline(providerToAdd.name); +}; + +/** Rendered as a constant drop target for creating a new OR group */ +export const EMPTY_GROUP: DataProvidersAnd[][] = [[]]; + +export const reArrangeProviders = ({ + dataProviders, + destination, + dispatch, + source, + timelineId, +}: { + dataProviders: DataProvider[]; + destination: DraggableLocation | undefined; + dispatch: Dispatch; + source: DraggableLocation; + timelineId: string; +}) => { + if (!isValidDestination(destination)) { + return; + } + + const dataProviderGroups = [...flattenIntoAndGroups(dataProviders), ...EMPTY_GROUP]; + + if (sourceAndDestinationAreSameDroppable({ source, destination })) { + reArrangeProvidersInSameGroup({ + dataProviderGroups, + destination, + dispatch, + source, + timelineId, + }); + } else { + moveProvidersBetweenGroups({ + dataProviderGroups, + destination, + dispatch, + source, + timelineId, + }); + } +}; + +export const addProviderToGroup = ({ + dataProviders, + destination, + dispatch, + onAddedToTimeline, + providerToAdd, + timelineId, +}: { + dataProviders: DataProvider[]; + destination: DraggableLocation | undefined; + dispatch: Dispatch; + onAddedToTimeline: (fieldOrValue: string) => void; + providerToAdd: DataProvider; + timelineId: string; +}) => { + const dataProviderGroups = [...flattenIntoAndGroups(dataProviders), ...EMPTY_GROUP]; + + if (!isValidDestination(destination)) { + return; + } + + const destinationGroupIndex = getGroupIndexFromDroppableId(destination.droppableId); + if ( + indexIsValid({ + index: destinationGroupIndex, + dataProviderGroups, + }) + ) { + const destinationGroup = dataProviderGroups[destinationGroupIndex]; + const destinationClone = [...destinationGroup]; + destinationClone.splice(destination.index, 0, omitAnd(providerToAdd)); // ⚠️ mutation + const deDuplicatedGroup = destinationClone.filter((provider, i) => + provider.id === providerToAdd.id && i !== destination.index ? false : true + ); + + const updatedGroups = dataProviderGroups.reduce( + (acc, group, i) => [ + ...acc, + i === destinationGroupIndex ? [...deDuplicatedGroup] : [...group], + ], + [] + ); + + dispatch( + updateProviders({ + id: timelineId, + providers: unFlattenGroups(updatedGroups.filter(g => g.length)), + }) + ); + onAddedToTimeline(providerToAdd.name); + } +}; + +export const addContentToTimeline = ({ + dataProviders, + destination, + dispatch, + onAddedToTimeline, + providerToAdd, + timelineId, +}: { + dataProviders: DataProvider[]; + destination: DraggableLocation | undefined; + dispatch: Dispatch; + onAddedToTimeline: (fieldOrValue: string) => void; + providerToAdd: DataProvider; + timelineId: string; +}) => { + if (dataProviders.length === 0) { + addProviderToEmptyTimeline({ dispatch, onAddedToTimeline, providerToAdd, timelineId }); + } else { + addProviderToGroup({ + dataProviders, + destination, + dispatch, + onAddedToTimeline, + providerToAdd, + timelineId, + }); + } +}; diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/index.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/index.tsx index f369b961807af..caead394db051 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/index.tsx +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/index.tsx @@ -15,8 +15,6 @@ import { IS_DRAGGING_CLASS_NAME, } from '../../drag_and_drop/helpers'; import { - OnChangeDataProviderKqlQuery, - OnChangeDroppableAndProvider, OnDataProviderEdited, OnDataProviderRemoved, OnToggleDataProviderEnabled, @@ -32,8 +30,6 @@ interface Props { browserFields: BrowserFields; id: string; dataProviders: DataProvider[]; - onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery; - onChangeDroppableAndProvider: OnChangeDroppableAndProvider; onDataProviderEdited: OnDataProviderEdited; onDataProviderRemoved: OnDataProviderRemoved; onToggleDataProviderEnabled: OnToggleDataProviderEnabled; @@ -42,6 +38,8 @@ interface Props { } const DropTargetDataProvidersContainer = styled.div` + padding: 2px 0 4px 0; + .${IS_DRAGGING_CLASS_NAME} & .drop-target-data-providers { background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.1)}; border: 0.2rem dashed ${({ theme }) => theme.eui.euiColorSuccess}; @@ -60,9 +58,6 @@ const DropTargetDataProviders = styled.div` position: relative; border: 0.2rem dashed ${props => props.theme.eui.euiColorMediumShade}; border-radius: 5px; - display: flex; - flex-direction: column; - justify-content: center; margin: 5px 0 5px 0; min-height: 100px; overflow-y: auto; @@ -95,43 +90,46 @@ export const DataProviders = React.memo( browserFields, id, dataProviders, - onChangeDataProviderKqlQuery, - onChangeDroppableAndProvider, onDataProviderEdited, onDataProviderRemoved, onToggleDataProviderEnabled, onToggleDataProviderExcluded, show, - }) => ( - - - - {({ isLoading }) => ( - - {dataProviders != null && dataProviders.length ? ( - - ) : ( - - )} - - )} - - - - ) + }) => { + return ( + + + + {({ isLoading }) => ( + <> + {dataProviders != null && dataProviders.length ? ( + + ) : ( + + + + )} + + )} + + + + ); + } ); DataProviders.displayName = 'DataProviders'; diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/provider_badge.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/provider_badge.tsx index e04aed17c6d67..859ced39ebc4f 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/provider_badge.tsx +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/provider_badge.tsx @@ -10,9 +10,8 @@ import { isString } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { ProviderContainer } from '../../drag_and_drop/provider_container'; import { getEmptyString } from '../../empty_value'; -import { WithCopyToClipboard } from '../../../lib/clipboard/with_copy_to_clipboard'; -import { WithHoverActions } from '../../with_hover_actions'; import { EXISTS_OPERATOR, QueryOperator } from './data_provider'; @@ -94,26 +93,13 @@ export const ProviderBadge = React.memo( const prefix = useMemo(() => (isExcluded ? {i18n.NOT} : null), [isExcluded]); - const title = useMemo(() => `${field}: "${formattedValue}"`, [field, formattedValue]); - - const hoverContent = useMemo( - () => ( - - ), - [field, val] - ); - - const badge = useCallback( - () => ( + return ( + ( )} - ), - [ - providerId, - field, - val, - classes, - title, - deleteFilter, - togglePopover, - formattedValue, - closeButtonProps, - prefix, - operator, - ] + ); - - return ; } ); diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_and.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_and.tsx deleted file mode 100644 index badc92d00c174..0000000000000 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_and.tsx +++ /dev/null @@ -1,95 +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 { EuiFlexItem } from '@elastic/eui'; -import React from 'react'; - -import { AndOrBadge } from '../../and_or_badge'; -import { BrowserFields } from '../../../containers/source'; -import { - OnChangeDataProviderKqlQuery, - OnDataProviderEdited, - OnDataProviderRemoved, - OnToggleDataProviderEnabled, - OnToggleDataProviderExcluded, -} from '../events'; - -import { DataProvidersAnd, IS_OPERATOR } from './data_provider'; -import { ProviderItemBadge } from './provider_item_badge'; - -interface ProviderItemAndPopoverProps { - browserFields: BrowserFields; - dataProvidersAnd: DataProvidersAnd[]; - onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery; - onDataProviderEdited: OnDataProviderEdited; - onDataProviderRemoved: OnDataProviderRemoved; - onToggleDataProviderEnabled: OnToggleDataProviderEnabled; - onToggleDataProviderExcluded: OnToggleDataProviderExcluded; - providerId: string; - timelineId: string; -} - -export class ProviderItemAnd extends React.PureComponent { - public render() { - const { - browserFields, - dataProvidersAnd, - onDataProviderEdited, - providerId, - timelineId, - } = this.props; - - return dataProvidersAnd.map((providerAnd: DataProvidersAnd, index: number) => ( - - - - - - this.deleteAndProvider(providerId, providerAnd.id)} - field={providerAnd.queryMatch.displayField || providerAnd.queryMatch.field} - kqlQuery={providerAnd.kqlQuery} - isEnabled={providerAnd.enabled} - isExcluded={providerAnd.excluded} - onDataProviderEdited={onDataProviderEdited} - operator={providerAnd.queryMatch.operator || IS_OPERATOR} - providerId={providerId} - timelineId={timelineId} - toggleEnabledProvider={() => - this.toggleEnabledAndProvider(providerId, !providerAnd.enabled, providerAnd.id) - } - toggleExcludedProvider={() => - this.toggleExcludedAndProvider(providerId, !providerAnd.excluded, providerAnd.id) - } - val={providerAnd.queryMatch.displayValue || providerAnd.queryMatch.value} - /> - - - )); - } - - private deleteAndProvider = (providerId: string, andProviderId: string) => { - this.props.onDataProviderRemoved(providerId, andProviderId); - }; - - private toggleEnabledAndProvider = ( - providerId: string, - enabled: boolean, - andProviderId: string - ) => { - this.props.onToggleDataProviderEnabled({ providerId, enabled, andProviderId }); - }; - - private toggleExcludedAndProvider = ( - providerId: string, - excluded: boolean, - andProviderId: string - ) => { - this.props.onToggleDataProviderExcluded({ providerId, excluded, andProviderId }); - }; -} diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_and_drag_drop.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_and_drag_drop.tsx deleted file mode 100644 index 3a691d2bbc621..0000000000000 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_and_drag_drop.tsx +++ /dev/null @@ -1,136 +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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { rgba } from 'polished'; -import React, { useCallback } from 'react'; -import styled from 'styled-components'; - -import { AndOrBadge } from '../../and_or_badge'; -import { - OnChangeDataProviderKqlQuery, - OnChangeDroppableAndProvider, - OnDataProviderEdited, - OnDataProviderRemoved, - OnToggleDataProviderEnabled, - OnToggleDataProviderExcluded, -} from '../events'; - -import { BrowserFields } from '../../../containers/source'; - -import { DataProvider } from './data_provider'; -import { ProviderItemAnd } from './provider_item_and'; - -import * as i18n from './translations'; - -const DropAndTargetDataProvidersContainer = styled(EuiFlexItem)` - margin: 0px 8px; -`; - -DropAndTargetDataProvidersContainer.displayName = 'DropAndTargetDataProvidersContainer'; - -const DropAndTargetDataProviders = styled.div<{ hasAndItem: boolean }>` - min-width: 230px; - width: auto; - border: 0.1rem dashed ${props => props.theme.eui.euiColorSuccess}; - border-radius: 5px; - text-align: center; - padding: 3px 10px; - display: flex; - justify-content: center; - align-items: center; - ${props => - props.hasAndItem - ? `&:hover { - transition: background-color 0.7s ease; - background-color: ${() => rgba(props.theme.eui.euiColorSuccess, 0.2)}; - }` - : ''}; - cursor: ${({ hasAndItem }) => (!hasAndItem ? `default` : 'inherit')}; -`; - -DropAndTargetDataProviders.displayName = 'DropAndTargetDataProviders'; - -const NumberProviderAndBadge = (styled(EuiBadge)` - margin: 0px 5px; -` as unknown) as typeof EuiBadge; - -NumberProviderAndBadge.displayName = 'NumberProviderAndBadge'; - -interface ProviderItemDropProps { - browserFields: BrowserFields; - dataProvider: DataProvider; - mousePosition?: { x: number; y: number; boundLeft: number; boundTop: number }; - onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery; - onChangeDroppableAndProvider: OnChangeDroppableAndProvider; - onDataProviderEdited: OnDataProviderEdited; - onDataProviderRemoved: OnDataProviderRemoved; - onToggleDataProviderEnabled: OnToggleDataProviderEnabled; - onToggleDataProviderExcluded: OnToggleDataProviderExcluded; - timelineId: string; -} - -export const ProviderItemAndDragDrop = React.memo( - ({ - browserFields, - dataProvider, - onChangeDataProviderKqlQuery, - onChangeDroppableAndProvider, - onDataProviderEdited, - onDataProviderRemoved, - onToggleDataProviderEnabled, - onToggleDataProviderExcluded, - timelineId, - }) => { - const onMouseEnter = useCallback(() => onChangeDroppableAndProvider(dataProvider.id), [ - onChangeDroppableAndProvider, - dataProvider.id, - ]); - const onMouseLeave = useCallback(() => onChangeDroppableAndProvider(''), [ - onChangeDroppableAndProvider, - ]); - const hasAndItem = dataProvider.and.length > 0; - return ( - - - - {hasAndItem && ( - - {dataProvider.and.length} - - )} - - {i18n.DROP_HERE_TO_ADD_AN} - - - - - - - ); - } -); - -ProviderItemAndDragDrop.displayName = 'ProviderItemAndDragDrop'; diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx index 2cc19537d6a63..b268315efb919 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx @@ -5,14 +5,16 @@ */ import { noop } from 'lodash/fp'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; import { BrowserFields } from '../../../containers/source'; import { OnDataProviderEdited } from '../events'; import { ProviderBadge } from './provider_badge'; import { ProviderItemActions } from './provider_item_actions'; -import { QueryOperator } from './data_provider'; +import { DataProvidersAnd, QueryOperator } from './data_provider'; +import { dragAndDropActions } from '../../../store/drag_and_drop'; import { TimelineContext } from '../timeline_context'; interface ProviderItemBadgeProps { @@ -26,6 +28,7 @@ interface ProviderItemBadgeProps { onDataProviderEdited?: OnDataProviderEdited; operator: QueryOperator; providerId: string; + register?: DataProvidersAnd; timelineId?: string; toggleEnabledProvider: () => void; toggleExcludedProvider: () => void; @@ -44,6 +47,7 @@ export const ProviderItemBadge = React.memo( onDataProviderEdited, operator, providerId, + register, timelineId, toggleEnabledProvider, toggleExcludedProvider, @@ -69,6 +73,31 @@ export const ProviderItemBadge = React.memo( closePopover(); }, [toggleExcludedProvider]); + const [providerRegistered, setProviderRegistered] = useState(false); + + const dispatch = useDispatch(); + + useEffect(() => { + // optionally register the provider if provided + if (!providerRegistered && register != null) { + dispatch(dragAndDropActions.registerProvider({ provider: { ...register, and: [] } })); + setProviderRegistered(true); + } + }, [providerRegistered, dispatch, register, setProviderRegistered]); + + const unRegisterProvider = useCallback(() => { + if (providerRegistered && register != null) { + dispatch(dragAndDropActions.unRegisterProvider({ id: register.id })); + } + }, [providerRegistered, dispatch, register]); + + useEffect( + () => () => { + unRegisterProvider(); + }, + [] + ); + return ( {({ isLoading }) => ( diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/providers.test.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/providers.test.tsx index 0c8a6932adf91..43e84bac508ea 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/providers.test.tsx +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/providers.test.tsx @@ -14,7 +14,7 @@ import { FilterManager } from '../../../../../../../src/plugins/data/public'; import { TimelineContext } from '../timeline_context'; import { mockDataProviders } from './mock/mock_data_providers'; -import { getDraggableId, Providers } from './providers'; +import { Providers } from './providers'; import { DELETE_CLASS_NAME, ENABLE_CLASS_NAME, EXCLUDE_CLASS_NAME } from './provider_item_actions'; import { useMountAppended } from '../../../utils/use_mount_appended'; @@ -32,8 +32,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -51,8 +49,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -80,8 +76,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={mockOnDataProviderRemoved} onToggleDataProviderEnabled={jest.fn()} @@ -107,8 +101,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={mockOnDataProviderRemoved} onToggleDataProviderEnabled={jest.fn()} @@ -136,8 +128,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={mockOnDataProviderRemoved} onToggleDataProviderEnabled={jest.fn()} @@ -170,8 +160,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={mockOnDataProviderRemoved} onToggleDataProviderEnabled={jest.fn()} @@ -197,14 +185,6 @@ describe('Providers', () => { }); }); - describe('#getDraggableId', () => { - test('it returns the expected id', () => { - expect(getDraggableId({ id: 'timeline1', dataProviderId: 'abcd' })).toEqual( - 'draggableId.timeline.timeline1.dataProvider.abcd' - ); - }); - }); - describe('#onToggleDataProviderEnabled', () => { test('it invokes the onToggleDataProviderEnabled callback when you click on the option "Temporary disable" in the provider menu', () => { const mockOnToggleDataProviderEnabled = jest.fn(); @@ -215,8 +195,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled} @@ -252,8 +230,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled} @@ -290,8 +266,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -330,8 +304,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -370,8 +342,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={dataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -403,8 +373,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={mockOnDataProviderRemoved} onToggleDataProviderEnabled={jest.fn()} @@ -439,8 +407,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={mockDataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={mockOnDataProviderRemoved} onToggleDataProviderEnabled={jest.fn()} @@ -475,8 +441,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={dataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled} @@ -520,8 +484,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={dataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled} @@ -561,8 +523,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={dataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -606,8 +566,6 @@ describe('Providers', () => { browserFields={{}} dataProviders={dataProviders} id="foo" - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/providers.tsx b/x-pack/plugins/siem/public/components/timeline/data_providers/providers.tsx index bfe99f6920e66..8d9d0c69d53cd 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/providers.tsx @@ -5,32 +5,35 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiFormHelpText } from '@elastic/eui'; -import React from 'react'; -import { Draggable } from 'react-beautiful-dnd'; -import styled from 'styled-components'; +import { rgba } from 'polished'; +import React, { useMemo } from 'react'; +import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd'; +import styled, { css } from 'styled-components'; +import { AndOrBadge } from '../../and_or_badge'; +import { BrowserFields } from '../../../containers/source'; +import { + getTimelineProviderDroppableId, + IS_DRAGGING_CLASS_NAME, + getTimelineProviderDraggableId, +} from '../../drag_and_drop/helpers'; import { - OnChangeDataProviderKqlQuery, - OnChangeDroppableAndProvider, OnDataProviderEdited, OnDataProviderRemoved, OnToggleDataProviderEnabled, OnToggleDataProviderExcluded, } from '../events'; -import { BrowserFields } from '../../../containers/source'; -import { DataProvider, IS_OPERATOR } from './data_provider'; -import { Empty } from './empty'; -import { ProviderItemAndDragDrop } from './provider_item_and_drag_drop'; +import { DataProvider, DataProvidersAnd, IS_OPERATOR } from './data_provider'; +import { EMPTY_GROUP, flattenIntoAndGroups } from './helpers'; import { ProviderItemBadge } from './provider_item_badge'; -import * as i18n from './translations'; + +export const EMPTY_PROVIDERS_GROUP_CLASS_NAME = 'empty-providers-group'; interface Props { browserFields: BrowserFields; id: string; dataProviders: DataProvider[]; - onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery; - onChangeDroppableAndProvider: OnChangeDroppableAndProvider; onDataProviderEdited: OnDataProviderEdited; onDataProviderRemoved: OnDataProviderRemoved; onToggleDataProviderEnabled: OnToggleDataProviderEnabled; @@ -42,68 +45,66 @@ interface Props { * (growth causes layout thrashing) when the AND drop target in a row * of data providers is revealed. */ -const ROW_OF_DATA_PROVIDERS_HEIGHT = 43; // px - -const PanelProviders = styled.div` - position: relative; - display: flex; - flex-direction: row; - min-height: 100px; - padding: 5px 10px 15px 0px; - overflow-y: auto; - align-items: stretch; - justify-content: flex-start; -`; +const ROW_OF_DATA_PROVIDERS_HEIGHT = 36; // px -PanelProviders.displayName = 'PanelProviders'; +const listStyle: React.CSSProperties = { + alignItems: 'center', + display: 'flex', + height: `${ROW_OF_DATA_PROVIDERS_HEIGHT}px`, + minWidth: '125px', +}; -const PanelProvidersGroupContainer = styled(EuiFlexGroup)` - position: relative; - flex-grow: unset; +const getItemStyle = ( + draggableStyle: DraggingStyle | NotDraggingStyle | undefined +): React.CSSProperties => ({ + ...draggableStyle, + userSelect: 'none', +}); - .euiFlexItem { - flex: 1 0 auto; - } +const DroppableContainer = styled.div` + height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px; - .euiFlexItem--flexGrowZero { - flex: 0 0 auto; + .${IS_DRAGGING_CLASS_NAME} &:hover { + background-color: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important; } `; -PanelProvidersGroupContainer.displayName = 'PanelProvidersGroupContainer'; +const Parens = styled.span` + ${({ theme }) => css` + color: ${theme.eui.euiColorMediumShade}; + font-size: 32px; + padding: 2px; + user-select: none; + `} +`; -/** A row of data providers in the timeline drop zone */ -const PanelProviderGroupContainer = styled(EuiFlexGroup)` - height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px; - min-height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px; - margin: 5px 0px; +const AndOrBadgeContainer = styled.div<{ hideBadge: boolean }>` + span { + visibility: ${({ hideBadge }) => (hideBadge ? 'hidden' : 'inherit')}; + } `; -PanelProviderGroupContainer.displayName = 'PanelProviderGroupContainer'; +const LastAndOrBadgeInGroup = styled.div` + display: none; -const PanelProviderItemContainer = styled(EuiFlexItem)` - position: relative; + .${IS_DRAGGING_CLASS_NAME} & { + display: initial; + } `; -PanelProviderItemContainer.displayName = 'PanelProviderItemContainer'; +const OrFlexItem = styled(EuiFlexItem)` + padding-left: 9px; +`; const TimelineEuiFormHelpText = styled(EuiFormHelpText)` padding-top: 0px; position: absolute; bottom: 0px; - left: 5px; + left: 4px; `; TimelineEuiFormHelpText.displayName = 'TimelineEuiFormHelpText'; -interface GetDraggableIdParams { - id: string; - dataProviderId: string; -} - -export const getDraggableId = ({ id, dataProviderId }: GetDraggableIdParams): string => - `draggableId.timeline.${id}.dataProvider.${dataProviderId}`; - /** * Renders an interactive card representation of the data providers. It also * affords uniform UI controls for the following actions: @@ -116,104 +117,151 @@ export const Providers = React.memo( browserFields, id, dataProviders, - onChangeDataProviderKqlQuery, - onChangeDroppableAndProvider, onDataProviderEdited, onDataProviderRemoved, onToggleDataProviderEnabled, onToggleDataProviderExcluded, - }) => ( - - 0} /> - - - {dataProviders.map((dataProvider, i) => { - const deleteProvider = () => onDataProviderRemoved(dataProvider.id); - const toggleEnabledProvider = () => - onToggleDataProviderEnabled({ - providerId: dataProvider.id, - enabled: !dataProvider.enabled, - }); - const toggleExcludedProvider = () => - onToggleDataProviderExcluded({ - providerId: dataProvider.id, - excluded: !dataProvider.excluded, - }); - return ( - // Providers are a special drop target that can't be drag-and-dropped - // to another destination, so it doesn't use our DraggableWrapper - { + // Transform the dataProviders into flattened groups, and append an empty group + const dataProviderGroups: DataProvidersAnd[][] = useMemo( + () => [...flattenIntoAndGroups(dataProviders), ...EMPTY_GROUP], + [dataProviders] + ); + + return ( + <> + {dataProviderGroups.map((group, groupIndex) => ( + + + + + + + + {'('} + + + - - ( + - {provided => ( -
( + - -
- )} -
-
- - - -
- ); - })} -
-
- - - {i18n.DROP_HERE} {i18n.TO_BUILD_AN} {i18n.OR.toLocaleUpperCase()} {i18n.QUERY} - - -
- ) + {(provided, snapshot) => ( +
+ + + 0 ? dataProvider.id : undefined} + browserFields={browserFields} + deleteProvider={() => + index > 0 + ? onDataProviderRemoved(group[0].id, dataProvider.id) + : onDataProviderRemoved(dataProvider.id) + } + field={ + index > 0 + ? dataProvider.queryMatch.displayField ?? + dataProvider.queryMatch.field + : group[0].queryMatch.displayField ?? + group[0].queryMatch.field + } + kqlQuery={index > 0 ? dataProvider.kqlQuery : group[0].kqlQuery} + isEnabled={index > 0 ? dataProvider.enabled : group[0].enabled} + isExcluded={index > 0 ? dataProvider.excluded : group[0].excluded} + onDataProviderEdited={onDataProviderEdited} + operator={ + index > 0 + ? dataProvider.queryMatch.operator ?? IS_OPERATOR + : group[0].queryMatch.operator ?? IS_OPERATOR + } + register={dataProvider} + providerId={index > 0 ? group[0].id : dataProvider.id} + timelineId={id} + toggleEnabledProvider={() => + index > 0 + ? onToggleDataProviderEnabled({ + providerId: group[0].id, + enabled: !dataProvider.enabled, + andProviderId: dataProvider.id, + }) + : onToggleDataProviderEnabled({ + providerId: dataProvider.id, + enabled: !dataProvider.enabled, + }) + } + toggleExcludedProvider={() => + index > 0 + ? onToggleDataProviderExcluded({ + providerId: group[0].id, + excluded: !dataProvider.excluded, + andProviderId: dataProvider.id, + }) + : onToggleDataProviderExcluded({ + providerId: dataProvider.id, + excluded: !dataProvider.excluded, + }) + } + val={ + dataProvider.queryMatch.displayValue ?? + dataProvider.queryMatch.value + } + /> + + + {!snapshot.isDragging && + (index < group.length - 1 ? ( + + ) : ( + + + + ))} + + +
+ )} + + ))} + {droppableProvided.placeholder} + + )} + +
+ + {')'} + + + ))} + + ); + } ); Providers.displayName = 'Providers'; diff --git a/x-pack/plugins/siem/public/components/timeline/data_providers/translations.ts b/x-pack/plugins/siem/public/components/timeline/data_providers/translations.ts index eec12177b8b72..56628502f5550 100644 --- a/x-pack/plugins/siem/public/components/timeline/data_providers/translations.ts +++ b/x-pack/plugins/siem/public/components/timeline/data_providers/translations.ts @@ -83,7 +83,7 @@ export const INCLUDE_DATA_PROVIDER = i18n.translate( ); export const NOT = i18n.translate('xpack.siem.dataProviders.not', { - defaultMessage: 'not', + defaultMessage: 'NOT', }); export const OR = i18n.translate('xpack.siem.dataProviders.or', { diff --git a/x-pack/plugins/siem/public/components/timeline/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/siem/public/components/timeline/header/__snapshots__/index.test.tsx.snap index 42a1d4cd7f0f0..1182cf4f44bc8 100644 --- a/x-pack/plugins/siem/public/components/timeline/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/siem/public/components/timeline/header/__snapshots__/index.test.tsx.snap @@ -139,8 +139,6 @@ exports[`Header rendering renders correctly against snapshot 1`] = ` ] } id="foo" - onChangeDataProviderKqlQuery={[MockFunction]} - onChangeDroppableAndProvider={[MockFunction]} onDataProviderEdited={[MockFunction]} onDataProviderRemoved={[MockFunction]} onToggleDataProviderEnabled={[MockFunction]} diff --git a/x-pack/plugins/siem/public/components/timeline/header/index.test.tsx b/x-pack/plugins/siem/public/components/timeline/header/index.test.tsx index 6f2053488f69b..7da76df497768 100644 --- a/x-pack/plugins/siem/public/components/timeline/header/index.test.tsx +++ b/x-pack/plugins/siem/public/components/timeline/header/index.test.tsx @@ -33,8 +33,6 @@ describe('Header', () => { filterManager={new FilterManager(mockUiSettingsForFilterManager)} id="foo" indexPattern={indexPattern} - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -55,8 +53,6 @@ describe('Header', () => { filterManager={new FilterManager(mockUiSettingsForFilterManager)} id="foo" indexPattern={indexPattern} - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} @@ -79,8 +75,6 @@ describe('Header', () => { filterManager={new FilterManager(mockUiSettingsForFilterManager)} id="foo" indexPattern={indexPattern} - onChangeDataProviderKqlQuery={jest.fn()} - onChangeDroppableAndProvider={jest.fn()} onDataProviderEdited={jest.fn()} onDataProviderRemoved={jest.fn()} onToggleDataProviderEnabled={jest.fn()} diff --git a/x-pack/plugins/siem/public/components/timeline/header/index.tsx b/x-pack/plugins/siem/public/components/timeline/header/index.tsx index 99964c955bafe..58e6b6e837249 100644 --- a/x-pack/plugins/siem/public/components/timeline/header/index.tsx +++ b/x-pack/plugins/siem/public/components/timeline/header/index.tsx @@ -12,8 +12,6 @@ import deepEqual from 'fast-deep-equal'; import { DataProviders } from '../data_providers'; import { DataProvider } from '../data_providers/data_provider'; import { - OnChangeDataProviderKqlQuery, - OnChangeDroppableAndProvider, OnDataProviderEdited, OnDataProviderRemoved, OnToggleDataProviderEnabled, @@ -30,8 +28,6 @@ interface Props { filterManager: FilterManager; id: string; indexPattern: IIndexPattern; - onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery; - onChangeDroppableAndProvider: OnChangeDroppableAndProvider; onDataProviderEdited: OnDataProviderEdited; onDataProviderRemoved: OnDataProviderRemoved; onToggleDataProviderEnabled: OnToggleDataProviderEnabled; @@ -46,8 +42,6 @@ const TimelineHeaderComponent: React.FC = ({ indexPattern, dataProviders, filterManager, - onChangeDataProviderKqlQuery, - onChangeDroppableAndProvider, onDataProviderEdited, onDataProviderRemoved, onToggleDataProviderEnabled, @@ -65,18 +59,19 @@ const TimelineHeaderComponent: React.FC = ({ size="s" /> )} - + {show && ( + + )} + ( start, updateDataProviderEnabled, updateDataProviderExcluded, - updateDataProviderKqlQuery, - updateHighlightedDropAndProviderId, updateItemsPerPage, upsertColumn, usersViewing, @@ -120,21 +116,11 @@ const StatefulTimelineComponent = React.memo( [id] ); - const onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery = useCallback( - ({ providerId, kqlQuery }) => updateDataProviderKqlQuery!({ id, kqlQuery, providerId }), - [id] - ); - const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback( itemsChangedPerPage => updateItemsPerPage!({ id, itemsPerPage: itemsChangedPerPage }), [id] ); - const onChangeDroppableAndProvider: OnChangeDroppableAndProvider = useCallback( - providerId => updateHighlightedDropAndProviderId!({ id, providerId }), - [id] - ); - const toggleColumn = useCallback( (column: ColumnHeaderOptions) => { const exists = columns.findIndex(c => c.id === column.id) !== -1; @@ -182,8 +168,6 @@ const StatefulTimelineComponent = React.memo( kqlMode={kqlMode} kqlQueryExpression={kqlQueryExpression} loadingIndexName={loading} - onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery} - onChangeDroppableAndProvider={onChangeDroppableAndProvider} onChangeItemsPerPage={onChangeItemsPerPage} onClose={onClose} onDataProviderEdited={onDataProviderEditedLocal} diff --git a/x-pack/plugins/siem/public/components/timeline/timeline.test.tsx b/x-pack/plugins/siem/public/components/timeline/timeline.test.tsx index 22f1c525a6c2a..0d0ce79c77be7 100644 --- a/x-pack/plugins/siem/public/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/siem/public/components/timeline/timeline.test.tsx @@ -65,8 +65,6 @@ describe('Timeline', () => { kqlMode: 'search' as TimelineComponentProps['kqlMode'], kqlQueryExpression: '', loadingIndexName: false, - onChangeDataProviderKqlQuery: jest.fn(), - onChangeDroppableAndProvider: jest.fn(), onChangeItemsPerPage: jest.fn(), onClose: jest.fn(), onDataProviderEdited: jest.fn(), diff --git a/x-pack/plugins/siem/public/components/timeline/timeline.tsx b/x-pack/plugins/siem/public/components/timeline/timeline.tsx index 10f10b1a86f1e..cc3116235557f 100644 --- a/x-pack/plugins/siem/public/components/timeline/timeline.tsx +++ b/x-pack/plugins/siem/public/components/timeline/timeline.tsx @@ -20,8 +20,6 @@ import { Sort } from './body/sort'; import { StatefulBody } from './body/stateful_body'; import { DataProvider } from './data_providers/data_provider'; import { - OnChangeDataProviderKqlQuery, - OnChangeDroppableAndProvider, OnChangeItemsPerPage, OnDataProviderRemoved, OnDataProviderEdited, @@ -99,8 +97,6 @@ export interface Props { kqlMode: KqlMode; kqlQueryExpression: string; loadingIndexName: boolean; - onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery; - onChangeDroppableAndProvider: OnChangeDroppableAndProvider; onChangeItemsPerPage: OnChangeItemsPerPage; onClose: () => void; onDataProviderEdited: OnDataProviderEdited; @@ -132,8 +128,6 @@ export const TimelineComponent: React.FC = ({ kqlMode, kqlQueryExpression, loadingIndexName, - onChangeDataProviderKqlQuery, - onChangeDroppableAndProvider, onChangeItemsPerPage, onClose, onDataProviderEdited, @@ -185,8 +179,6 @@ export const TimelineComponent: React.FC = ({ indexPattern={indexPattern} dataProviders={dataProviders} filterManager={filterManager} - onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery} - onChangeDroppableAndProvider={onChangeDroppableAndProvider} onDataProviderEdited={onDataProviderEdited} onDataProviderRemoved={onDataProviderRemoved} onToggleDataProviderEnabled={onToggleDataProviderEnabled} diff --git a/x-pack/plugins/siem/public/containers/case/configure/mock.ts b/x-pack/plugins/siem/public/containers/case/configure/mock.ts index c6824bd50edb5..88e1793aa15c1 100644 --- a/x-pack/plugins/siem/public/containers/case/configure/mock.ts +++ b/x-pack/plugins/siem/public/containers/case/configure/mock.ts @@ -30,7 +30,7 @@ export const mapping: CasesConfigurationMapping[] = [ ]; export const connectorsMock: Connector[] = [ { - id: '123', + id: 'servicenow-1', actionTypeId: '.servicenow', name: 'My Connector', config: { @@ -42,7 +42,7 @@ export const connectorsMock: Connector[] = [ isPreconfigured: false, }, { - id: '456', + id: 'servicenow-2', actionTypeId: '.servicenow', name: 'My Connector 2', config: { @@ -69,6 +69,34 @@ export const connectorsMock: Connector[] = [ }, isPreconfigured: false, }, + { + id: 'jira-1', + actionTypeId: '.jira', + name: 'Jira', + config: { + apiUrl: 'https://instance.atlassian.ne', + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'summary', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + }, + isPreconfigured: false, + }, ]; export const caseConfigurationResposeMock: CasesConfigureResponse = { diff --git a/x-pack/plugins/siem/public/containers/case/mock.ts b/x-pack/plugins/siem/public/containers/case/mock.ts index a3a8db2c40950..8c55e55693963 100644 --- a/x-pack/plugins/siem/public/containers/case/mock.ts +++ b/x-pack/plugins/siem/public/containers/case/mock.ts @@ -19,6 +19,7 @@ import { CasesFindResponse, } from '../../../../case/common/api/cases'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; +export { connectorsMock } from './configure/mock'; export const basicCaseId = 'basic-case-id'; const basicCommentId = 'basic-comment-id'; @@ -31,6 +32,11 @@ export const elasticUser = { email: 'leslie.knope@elastic.co', }; +export const serviceConnectorUser = { + fullName: 'Leslie Knope', + username: 'lknope', +}; + export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { @@ -52,6 +58,7 @@ export const basicCase: Case = { comments: [basicComment], createdAt: basicCreatedAt, createdBy: elasticUser, + connectorId: '123', description: 'Security banana Issue', externalService: null, status: 'open', @@ -87,8 +94,8 @@ export const casesStatus: CasesStatus = { countOpenCases: 20, }; -const basicPush = { - connectorId: 'connector_id', +export const basicPush = { + connectorId: '123', connectorName: 'connector name', externalId: 'external_id', externalTitle: 'external title', @@ -192,6 +199,7 @@ export const basicCaseSnake: CaseResponse = { closed_at: null, closed_by: null, comments: [basicCommentSnake], + connector_id: '123', created_at: basicCreatedAt, created_by: elasticUserSnake, external_service: null, @@ -205,13 +213,13 @@ export const casesStatusSnake: CasesStatusResponse = { }; export const pushSnake = { - connector_id: 'connector_id', + connector_id: '123', connector_name: 'connector name', external_id: 'external_id', external_title: 'external title', external_url: 'basicPush.com', }; -const basicPushSnake = { +export const basicPushSnake = { ...pushSnake, pushed_at: basicUpdatedAt, pushed_by: elasticUserSnake, diff --git a/x-pack/plugins/siem/public/containers/case/types.ts b/x-pack/plugins/siem/public/containers/case/types.ts index dde13dc38aca8..648276cbc3c41 100644 --- a/x-pack/plugins/siem/public/containers/case/types.ts +++ b/x-pack/plugins/siem/public/containers/case/types.ts @@ -43,6 +43,7 @@ export interface Case { closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; + connectorId: string; createdAt: string; createdBy: ElasticUser; description: string; diff --git a/x-pack/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/plugins/siem/public/containers/case/use_get_case.tsx index b2e3b6d0cacf6..06d4c38ddda49 100644 --- a/x-pack/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_get_case.tsx @@ -59,6 +59,7 @@ export const initialData: Case = { closedBy: null, createdAt: '', comments: [], + connectorId: 'none', createdBy: { username: '', }, diff --git a/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx b/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx index cdd40b84f8724..0848d12c8d308 100644 --- a/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx @@ -6,11 +6,19 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { + getPushedInfo, initialData, useGetCaseUserActions, UseGetCaseUserActions, } from './use_get_case_user_actions'; -import { basicCaseId, caseUserActions, elasticUser } from './mock'; +import { + basicCase, + basicPush, + basicPushSnake, + caseUserActions, + elasticUser, + getUserAction, +} from './mock'; import * as api from './api'; jest.mock('./api'); @@ -25,7 +33,7 @@ describe('useGetCaseUserActions', () => { it('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -40,23 +48,23 @@ describe('useGetCaseUserActions', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); - result.current.fetchCaseUserActions(basicCaseId); + result.current.fetchCaseUserActions(basicCase.id); await waitForNextUpdate(); - expect(spyOnPostCase).toBeCalledWith(basicCaseId, abortCtrl.signal); + expect(spyOnPostCase).toBeCalledWith(basicCase.id, abortCtrl.signal); }); }); - it('retuns proper state on getCaseUserActions', async () => { + it('returns proper state on getCaseUserActions', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); - result.current.fetchCaseUserActions(basicCaseId); + result.current.fetchCaseUserActions(basicCase.id); await waitForNextUpdate(); expect(result.current).toEqual({ ...initialData, @@ -73,10 +81,10 @@ describe('useGetCaseUserActions', () => { it('set isLoading to true when posting case', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); - result.current.fetchCaseUserActions(basicCaseId); + result.current.fetchCaseUserActions(basicCase.id); expect(result.current.isLoading).toBe(true); }); @@ -90,10 +98,10 @@ describe('useGetCaseUserActions', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); - result.current.fetchCaseUserActions(basicCaseId); + result.current.fetchCaseUserActions(basicCase.id); expect(result.current).toEqual({ ...initialData, @@ -103,4 +111,165 @@ describe('useGetCaseUserActions', () => { }); }); }); + describe('getPushedInfo', () => { + it('Correctly marks first/last index - hasDataToPush: false', () => { + const userActions = [...caseUserActions, getUserAction(['pushed'], 'push-to-service')]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: false, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: false, + }, + }, + }); + }); + + it('Correctly marks first/last index - hasDataToPush: true', () => { + const userActions = [ + ...caseUserActions, + getUserAction(['pushed'], 'push-to-service'), + getUserAction(['comment'], 'create'), + ]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: true, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: true, + }, + }, + }); + }); + + it('Does not count connector_id update as a reason to push', () => { + const userActions = [ + ...caseUserActions, + getUserAction(['pushed'], 'push-to-service'), + getUserAction(['connector_id'], 'update'), + ]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: false, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: false, + }, + }, + }); + }); + it('Correctly handles multiple push actions', () => { + const userActions = [ + ...caseUserActions, + getUserAction(['pushed'], 'push-to-service'), + getUserAction(['comment'], 'create'), + getUserAction(['pushed'], 'push-to-service'), + ]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: false, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 5, + hasDataToPush: false, + }, + }, + }); + }); + + it('Multiple connector tracking - hasDataToPush: true', () => { + const pushAction123 = getUserAction(['pushed'], 'push-to-service'); + const push456 = { + ...basicPushSnake, + connector_id: '456', + connector_name: 'other connector name', + external_id: 'other_external_id', + }; + const pushAction456 = { + ...getUserAction(['pushed'], 'push-to-service'), + newValue: JSON.stringify(push456), + }; + + const userActions = [ + ...caseUserActions, + pushAction123, + getUserAction(['comment'], 'create'), + pushAction456, + ]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: true, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: true, + }, + '456': { + ...basicPush, + connectorId: '456', + connectorName: 'other connector name', + externalId: 'other_external_id', + firstPushIndex: 5, + lastPushIndex: 5, + hasDataToPush: false, + }, + }, + }); + }); + + it('Multiple connector tracking - hasDataToPush: false', () => { + const pushAction123 = getUserAction(['pushed'], 'push-to-service'); + const push456 = { + ...basicPushSnake, + connector_id: '456', + connector_name: 'other connector name', + external_id: 'other_external_id', + }; + const pushAction456 = { + ...getUserAction(['pushed'], 'push-to-service'), + newValue: JSON.stringify(push456), + }; + + const userActions = [ + ...caseUserActions, + pushAction123, + getUserAction(['comment'], 'create'), + pushAction456, + ]; + const result = getPushedInfo(userActions, '456'); + expect(result).toEqual({ + hasDataToPush: false, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: true, + }, + '456': { + ...basicPush, + connectorId: '456', + connectorName: 'other connector name', + externalId: 'other_external_id', + firstPushIndex: 5, + lastPushIndex: 5, + hasDataToPush: false, + }, + }, + }); + }); + }); }); diff --git a/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.tsx index 6d9874a655e97..a2290f946be9b 100644 --- a/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -10,25 +10,35 @@ import { useCallback, useEffect, useState } from 'react'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { getCaseUserActions } from './api'; import * as i18n from './translations'; -import { CaseUserActions, ElasticUser } from './types'; +import { CaseExternalService, CaseUserActions, ElasticUser } from './types'; +import { convertToCamelCase, parseString } from './utils'; +import { CaseFullExternalService } from '../../../../case/common/api/cases'; + +interface CaseService extends CaseExternalService { + firstPushIndex: number; + lastPushIndex: number; + hasDataToPush: boolean; +} + +export interface CaseServices { + [key: string]: CaseService; +} interface CaseUserActionsState { + caseServices: CaseServices; caseUserActions: CaseUserActions[]; - firstIndexPushToService: number; hasDataToPush: boolean; - participants: ElasticUser[]; - isLoading: boolean; isError: boolean; - lastIndexPushToService: number; + isLoading: boolean; + participants: ElasticUser[]; } export const initialData: CaseUserActionsState = { + caseServices: {}, caseUserActions: [], - firstIndexPushToService: -1, - lastIndexPushToService: -1, hasDataToPush: false, - isLoading: true, isError: false, + isLoading: true, participants: [], }; @@ -36,26 +46,72 @@ export interface UseGetCaseUserActions extends CaseUserActionsState { fetchCaseUserActions: (caseId: string) => void; } -const getPushedInfo = ( - caseUserActions: CaseUserActions[] -): { firstIndexPushToService: number; lastIndexPushToService: number; hasDataToPush: boolean } => { - const firstIndexPushToService = caseUserActions.findIndex( - cua => cua.action === 'push-to-service' - ); - const lastIndexPushToService = caseUserActions - .map(cua => cua.action) - .lastIndexOf('push-to-service'); +const getExternalService = (value: string): CaseExternalService | null => + convertToCamelCase(parseString(`${value}`)); + +export const getPushedInfo = ( + caseUserActions: CaseUserActions[], + caseConnectorId: string +): { + caseServices: CaseServices; + hasDataToPush: boolean; +} => { + const hasDataToPushForConnector = (connectorId: string) => { + const userActionsForPushLessServiceUpdates = caseUserActions.filter( + mua => + (mua.action !== 'push-to-service' && + !(mua.action === 'update' && mua.actionField[0] === 'connector_id')) || + (mua.action === 'push-to-service' && + connectorId === getExternalService(`${mua.newValue}`)?.connectorId) + ); + return ( + userActionsForPushLessServiceUpdates[userActionsForPushLessServiceUpdates.length - 1] + .action !== 'push-to-service' + ); + }; + + const caseServices = caseUserActions.reduce((acc, cua, i) => { + if (cua.action !== 'push-to-service') { + return acc; + } + const externalService = getExternalService(`${cua.newValue}`); + if (externalService === null) { + return acc; + } + + return { + ...acc, + ...(acc[externalService.connectorId] != null + ? { + [externalService.connectorId]: { + ...acc[externalService.connectorId], + ...externalService, + lastPushIndex: i, + }, + } + : { + [externalService.connectorId]: { + ...externalService, + firstPushIndex: i, + lastPushIndex: i, + hasDataToPush: hasDataToPushForConnector(externalService.connectorId), + }, + }), + }; + }, {}); const hasDataToPush = - lastIndexPushToService === -1 || lastIndexPushToService < caseUserActions.length - 1; + caseServices[caseConnectorId] != null ? caseServices[caseConnectorId].hasDataToPush : true; return { - firstIndexPushToService, - lastIndexPushToService, hasDataToPush, + caseServices, }; }; -export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => { +export const useGetCaseUserActions = ( + caseId: string, + caseConnectorId: string +): UseGetCaseUserActions => { const [caseUserActionsState, setCaseUserActionsState] = useState( initialData ); @@ -84,7 +140,7 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => const caseUserActions = !isEmpty(response) ? response.slice(1) : []; setCaseUserActionsState({ caseUserActions, - ...getPushedInfo(caseUserActions), + ...getPushedInfo(caseUserActions, caseConnectorId), isLoading: false, isError: false, participants, @@ -98,12 +154,11 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => dispatchToaster, }); setCaseUserActionsState({ + caseServices: {}, caseUserActions: [], - firstIndexPushToService: -1, - lastIndexPushToService: -1, hasDataToPush: false, - isLoading: false, isError: true, + isLoading: false, participants: [], }); } @@ -115,13 +170,13 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => abortCtrl.abort(); }; }, - [caseUserActionsState] + [caseUserActionsState, caseConnectorId] ); useEffect(() => { if (!isEmpty(caseId)) { fetchCaseUserActions(caseId); } - }, [caseId]); + }, [caseId, caseConnectorId]); return { ...caseUserActionsState, fetchCaseUserActions }; }; diff --git a/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx b/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx index b9698c3e864e3..72609e15d1ec4 100644 --- a/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx @@ -10,7 +10,14 @@ import { usePostPushToService, UsePostPushToService, } from './use_post_push_to_service'; -import { basicCase, pushedCase, serviceConnector } from './mock'; +import { + basicCase, + basicComment, + basicPush, + pushedCase, + serviceConnector, + serviceConnectorUser, +} from './mock'; import * as api from './api'; jest.mock('./api'); @@ -20,10 +27,54 @@ describe('usePostPushToService', () => { const updateCase = jest.fn(); const samplePush = { caseId: pushedCase.id, - connectorName: 'sample', - connectorId: '22', + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 1, + lastPushIndex: 1, + hasDataToPush: false, + }, + }, + connectorName: 'connector name', + connectorId: '123', updateCase, }; + const sampleServiceRequestData = { + caseId: pushedCase.id, + createdAt: pushedCase.createdAt, + createdBy: serviceConnectorUser, + comments: [ + { + commentId: basicComment.id, + comment: basicComment.comment, + createdAt: basicComment.createdAt, + createdBy: serviceConnectorUser, + updatedAt: null, + updatedBy: null, + }, + ], + externalId: basicPush.externalId, + description: pushedCase.description, + title: pushedCase.title, + updatedAt: pushedCase.updatedAt, + updatedBy: serviceConnectorUser, + }; + const sampleCaseServices = { + '123': { + ...basicPush, + firstPushIndex: 1, + lastPushIndex: 1, + hasDataToPush: true, + }, + '456': { + ...basicPush, + connectorId: '456', + externalId: 'other_external_id', + firstPushIndex: 4, + lastPushIndex: 6, + hasDataToPush: false, + }, + }; it('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -76,7 +127,7 @@ describe('usePostPushToService', () => { await waitForNextUpdate(); expect(spyOnPushToService).toBeCalledWith( samplePush.connectorId, - formatServiceRequestData(basicCase), + formatServiceRequestData(basicCase, 'none', {}), abortCtrl.signal ); }); @@ -111,6 +162,32 @@ describe('usePostPushToService', () => { }); }); + it('formatServiceRequestData - current connector', () => { + const caseServices = sampleCaseServices; + const result = formatServiceRequestData(pushedCase, '123', caseServices); + expect(result).toEqual(sampleServiceRequestData); + }); + + it('formatServiceRequestData - connector with history', () => { + const caseServices = sampleCaseServices; + const result = formatServiceRequestData(pushedCase, '456', caseServices); + expect(result).toEqual({ + ...sampleServiceRequestData, + externalId: 'other_external_id', + }); + }); + + it('formatServiceRequestData - new connector', () => { + const caseServices = { + '123': sampleCaseServices['123'], + }; + const result = formatServiceRequestData(pushedCase, '456', caseServices); + expect(result).toEqual({ + ...sampleServiceRequestData, + externalId: null, + }); + }); + it('unhappy path', async () => { const spyOnPushToService = jest.spyOn(api, 'pushToService'); spyOnPushToService.mockImplementation(() => { diff --git a/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.tsx index c9d1b963f411a..3d0836cdc8adf 100644 --- a/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -15,6 +15,7 @@ import { errorToToaster, useStateToaster, displaySuccessToast } from '../../comp import { getCase, pushToService, pushCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; +import { CaseServices } from './use_get_case_user_actions'; interface PushToServiceState { serviceData: ServiceConnectorCaseResponse | null; @@ -65,11 +66,18 @@ interface PushToServiceRequest { caseId: string; connectorId: string; connectorName: string; + caseServices: CaseServices; updateCase: (newCase: Case) => void; } export interface UsePostPushToService extends PushToServiceState { - postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void; + postPushToService: ({ + caseId, + caseServices, + connectorId, + connectorName, + updateCase, + }: PushToServiceRequest) => void; } export const usePostPushToService = (): UsePostPushToService => { @@ -82,7 +90,13 @@ export const usePostPushToService = (): UsePostPushToService => { const [, dispatchToaster] = useStateToaster(); const postPushToService = useCallback( - async ({ caseId, connectorId, connectorName, updateCase }: PushToServiceRequest) => { + async ({ + caseId, + caseServices, + connectorId, + connectorName, + updateCase, + }: PushToServiceRequest) => { let cancel = false; const abortCtrl = new AbortController(); try { @@ -90,7 +104,7 @@ export const usePostPushToService = (): UsePostPushToService => { const casePushData = await getCase(caseId, true, abortCtrl.signal); const responseService = await pushToService( connectorId, - formatServiceRequestData(casePushData), + formatServiceRequestData(casePushData, connectorId, caseServices), abortCtrl.signal ); const responseCase = await pushCase( @@ -131,7 +145,11 @@ export const usePostPushToService = (): UsePostPushToService => { return { ...state, postPushToService }; }; -export const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { +export const formatServiceRequestData = ( + myCase: Case, + connectorId: string, + caseServices: CaseServices +): ServiceConnectorCaseParams => { const { id: caseId, createdAt, @@ -143,6 +161,20 @@ export const formatServiceRequestData = (myCase: Case): ServiceConnectorCasePara updatedAt, updatedBy, } = myCase; + let actualExternalService = externalService; + if ( + externalService != null && + externalService.connectorId !== connectorId && + caseServices[connectorId] + ) { + actualExternalService = caseServices[connectorId]; + } else if ( + externalService != null && + externalService.connectorId !== connectorId && + !caseServices[connectorId] + ) { + actualExternalService = null; + } return { caseId, createdAt, @@ -180,7 +212,7 @@ export const formatServiceRequestData = (myCase: Case): ServiceConnectorCasePara : null, })), description, - externalId: externalService?.externalId ?? null, + externalId: actualExternalService?.externalId ?? null, title, updatedAt, updatedBy: diff --git a/x-pack/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/plugins/siem/public/containers/case/use_update_case.tsx index 2f2fe18321246..af824674999b9 100644 --- a/x-pack/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_update_case.tsx @@ -12,7 +12,10 @@ import { patchCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; -export type UpdateKey = keyof Pick; +export type UpdateKey = keyof Pick< + CasePatchRequest, + 'connector_id' | 'description' | 'status' | 'tags' | 'title' +>; interface NewCaseState { isLoading: boolean; diff --git a/x-pack/plugins/siem/public/containers/case/utils.ts b/x-pack/plugins/siem/public/containers/case/utils.ts index aaa5ff4ab44c1..15e514d6ea8b3 100644 --- a/x-pack/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/plugins/siem/public/containers/case/utils.ts @@ -31,6 +31,14 @@ import { AllCases, Case } from './types'; export const getTypedPayload = (a: unknown): T => a as T; +export const parseString = (params: string) => { + try { + return JSON.parse(params); + } catch { + return null; + } +}; + export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => arrayOfSnakes.reduce((acc: unknown[], value) => { if (isArray(value)) { diff --git a/x-pack/plugins/siem/public/containers/timeline/one/index.gql_query.ts b/x-pack/plugins/siem/public/containers/timeline/one/index.gql_query.ts index e68db445a5cbb..d70a419b99a3b 100644 --- a/x-pack/plugins/siem/public/containers/timeline/one/index.gql_query.ts +++ b/x-pack/plugins/siem/public/containers/timeline/one/index.gql_query.ts @@ -129,6 +129,9 @@ export const oneTimelineQuery = gql` version } title + timelineType + templateTimelineId + templateTimelineVersion savedQueryId sort { columnId diff --git a/x-pack/plugins/siem/public/graphql/types.ts b/x-pack/plugins/siem/public/graphql/types.ts index 8c39d5e58b99e..86890988c06b6 100644 --- a/x-pack/plugins/siem/public/graphql/types.ts +++ b/x-pack/plugins/siem/public/graphql/types.ts @@ -5112,6 +5112,12 @@ export namespace GetOneTimeline { title: Maybe; + timelineType: Maybe; + + templateTimelineId: Maybe; + + templateTimelineVersion: Maybe; + savedQueryId: Maybe; sort: Maybe; diff --git a/x-pack/plugins/siem/public/hooks/translations.ts b/x-pack/plugins/siem/public/hooks/translations.ts index ba3ec40df466a..40db748a3e1ac 100644 --- a/x-pack/plugins/siem/public/hooks/translations.ts +++ b/x-pack/plugins/siem/public/hooks/translations.ts @@ -6,6 +6,12 @@ import { i18n } from '@kbn/i18n'; +export const ADDED_TO_TIMELINE_MESSAGE = (fieldOrValue: string) => + i18n.translate('xpack.siem.hooks.useAddToTimeline.addedFieldMessage', { + values: { fieldOrValue }, + defaultMessage: `Added {fieldOrValue} to timeline`, + }); + export const STATUS_CODE = i18n.translate( 'xpack.siem.components.ml.api.errors.statusCodeFailureTitle', { diff --git a/x-pack/plugins/siem/public/hooks/use_add_to_timeline.tsx b/x-pack/plugins/siem/public/hooks/use_add_to_timeline.tsx new file mode 100644 index 0000000000000..be0ddb153457e --- /dev/null +++ b/x-pack/plugins/siem/public/hooks/use_add_to_timeline.tsx @@ -0,0 +1,165 @@ +/* + * 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 d3 from 'd3'; +import { useCallback } from 'react'; +import { DraggableId, FluidDragActions, Position, SensorAPI } from 'react-beautiful-dnd'; + +import { IS_DRAGGING_CLASS_NAME } from '../components/drag_and_drop/helpers'; +import { HIGHLIGHTED_DROP_TARGET_CLASS_NAME } from '../components/timeline/data_providers/empty'; +import { EMPTY_PROVIDERS_GROUP_CLASS_NAME } from '../components/timeline/data_providers/providers'; + +let _sensorApiSingleton: SensorAPI; + +/** + * This hook is passed (in an array) to the `sensors` prop of the + * `react-beautiful-dnd` `DragDropContext` component. Example: + * + * ``` + + {children} + * + * ``` + * + * As a side effect of registering this hook with the `DragDropContext`, + * the `SensorAPI` singleton is initialized. This singleton is used + * by the `useAddToTimeline` hook. + */ +export const useAddToTimelineSensor = (api: SensorAPI) => { + _sensorApiSingleton = api; +}; + +/** + * Returns the position of the specified element + */ +const getPosition = (element: Element): Position => { + const rect = element.getBoundingClientRect(); + + return { x: rect.left, y: rect.top }; +}; + +/** + * Returns the position of one of the following timeline drop targets + * (in the following order of preference): + * 1) The "Drop anything highlighted..." drop target + * 2) The persistent "empty" data provider group drop target + * 3) `null`, because none of the above targets exist (an error state) + */ +export const getDropTargetCoordinate = (): Position | null => { + // The placeholder in the "Drop anything highlighted here to build an OR query": + const highlighted = document.querySelector(`.${HIGHLIGHTED_DROP_TARGET_CLASS_NAME}`); + + if (highlighted != null) { + return getPosition(highlighted); + } + + // If at least one provider has been added to the timeline, the "Drop anything + // highlighted..." drop target won't be visible, so we need to drop into the + // empty group instead: + const emptyGroup = document.querySelector(`.${EMPTY_PROVIDERS_GROUP_CLASS_NAME}`); + + if (emptyGroup != null) { + return getPosition(emptyGroup); + } + + return null; +}; + +/** + * Returns the coordinates of the specified draggable + */ +export const getDraggableCoordinate = (draggableId: DraggableId): Position | null => { + // The placeholder in the "Drop anything highlighted here to build an OR query": + const draggable = document.querySelector(`[data-rbd-draggable-id="${draggableId}"]`); + + if (draggable != null) { + return getPosition(draggable); + } + + return null; +}; + +/** + * Animates a draggable via `requestAnimationFrame` + */ +export const animate = ({ + drag, + fieldName, + values, +}: { + drag: FluidDragActions; + fieldName: string; + values: Position[]; +}) => { + requestAnimationFrame(() => { + if (values.length === 0) { + setTimeout(() => drag.drop(), 0); // schedule the drop the next time around + return; + } + + drag.move(values[0]); + + animate({ + drag, + fieldName, + values: values.slice(1), + }); + }); +}; + +/** + * This hook animates a draggable data provider to the timeline + */ +export const useAddToTimeline = ({ + draggableId, + fieldName, +}: { + draggableId: DraggableId | undefined; + fieldName: string; +}) => { + const startDragToTimeline = useCallback(() => { + if (_sensorApiSingleton == null) { + throw new TypeError( + 'To use this hook, the companion `useAddToTimelineSensor` hook must be registered in the `sensors` prop of the `DragDropContext`.' + ); + } + + if (draggableId == null) { + // A request to start the animation should not have been made, because + // no draggableId was provided + return; + } + + // add the dragging class, which will show the flyout data providers (if the flyout button is being displayed): + document.body.classList.add(IS_DRAGGING_CLASS_NAME); + + // start the animation after the flyout data providers are visible: + setTimeout(() => { + const draggableCoordinate = getDraggableCoordinate(draggableId); + const dropTargetCoordinate = getDropTargetCoordinate(); + const preDrag = _sensorApiSingleton.tryGetLock(draggableId); + + if (draggableCoordinate != null && dropTargetCoordinate != null && preDrag != null) { + const steps = 10; + const points = d3.range(steps + 1).map(i => ({ + x: d3.interpolate(draggableCoordinate.x, dropTargetCoordinate.x)(i * 0.1), + y: d3.interpolate(draggableCoordinate.y, dropTargetCoordinate.y)(i * 0.1), + })); + + const drag = preDrag.fluidLift(draggableCoordinate); + animate({ + drag, + fieldName, + values: points, + }); + } else { + document.body.classList.remove(IS_DRAGGING_CLASS_NAME); // it was not possible to perform a drag and drop + } + }, 0); + }, [_sensorApiSingleton, draggableId]); + + return startDragToTimeline; +}; diff --git a/x-pack/plugins/siem/public/hooks/use_providers_portal.tsx b/x-pack/plugins/siem/public/hooks/use_providers_portal.tsx new file mode 100644 index 0000000000000..1099215f755ee --- /dev/null +++ b/x-pack/plugins/siem/public/hooks/use_providers_portal.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; +import { createPortalNode } from 'react-reverse-portal'; + +/** + * A singleton portal for rendering the draggable groups of providers in the + * header of the timeline, or in the animated flyout + */ +const proivdersPortalNodeSingleton = createPortalNode(); + +export const useProvidersPortal = () => { + const [proivdersPortalNode] = useState(proivdersPortalNodeSingleton); + + return proivdersPortalNode; +}; diff --git a/x-pack/plugins/siem/public/lib/connectors/components/connector_flyout/index.tsx b/x-pack/plugins/siem/public/lib/connectors/components/connector_flyout/index.tsx index c5a35da56284d..10b1e75c6ea84 100644 --- a/x-pack/plugins/siem/public/lib/connectors/components/connector_flyout/index.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/components/connector_flyout/index.tsx @@ -13,14 +13,16 @@ import { isEmpty, get } from 'lodash/fp'; import { ActionConnectorFieldsProps } from '../../../../../../triggers_actions_ui/public/types'; import { FieldMapping } from '../../../../pages/case/components/configure_cases/field_mapping'; -import { defaultMapping } from '../../config'; import { CasesConfigurationMapping } from '../../../../containers/case/configure/types'; import * as i18n from '../../translations'; import { ActionConnector, ConnectorFlyoutHOCProps } from '../../types'; +import { createDefaultMapping } from '../../utils'; +import { connectorsConfiguration } from '../../config'; export const withConnectorFlyout = ({ ConnectorFormComponent, + connectorActionTypeId, secretKeys = [], configKeys = [], }: ConnectorFlyoutHOCProps) => { @@ -56,7 +58,7 @@ export const withConnectorFlyout = ({ if (isEmpty(mapping)) { editActionConfig('casesConfiguration', { ...action.config.casesConfiguration, - mapping: defaultMapping, + mapping: createDefaultMapping(connectorsConfiguration[connectorActionTypeId].fields), }); } @@ -135,6 +137,7 @@ export const withConnectorFlyout = ({ diff --git a/x-pack/plugins/siem/public/lib/connectors/config.ts b/x-pack/plugins/siem/public/lib/connectors/config.ts index 98473e49622a9..d8b55665f7768 100644 --- a/x-pack/plugins/siem/public/lib/connectors/config.ts +++ b/x-pack/plugins/siem/public/lib/connectors/config.ts @@ -4,31 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CasesConfigurationMapping } from '../../containers/case/configure/types'; - -import { Connector } from './types'; import { connector as serviceNowConnectorConfig } from './servicenow/config'; import { connector as jiraConnectorConfig } from './jira/config'; +import { ConnectorConfiguration } from './types'; -export const connectorsConfiguration: Record = { +export const connectorsConfiguration: Record = { '.servicenow': serviceNowConnectorConfig, '.jira': jiraConnectorConfig, }; - -export const defaultMapping: CasesConfigurationMapping[] = [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, -]; diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/config.ts b/x-pack/plugins/siem/public/lib/connectors/jira/config.ts index 42bd1b9cdc191..e6151a54bff74 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/config.ts +++ b/x-pack/plugins/siem/public/lib/connectors/jira/config.ts @@ -4,17 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Connector } from '../types'; +import { ConnectorConfiguration } from './types'; -import { JIRA_TITLE } from './translations'; +import * as i18n from './translations'; import logo from './logo.svg'; -export const connector: Connector = { +export const connector: ConnectorConfiguration = { id: '.jira', - name: JIRA_TITLE, + name: i18n.JIRA_TITLE, logo, enabled: true, enabledInConfig: true, enabledInLicense: true, minimumLicenseRequired: 'platinum', + fields: { + summary: { + label: i18n.MAPPING_FIELD_SUMMARY, + validSourceFields: ['title', 'description'], + defaultSourceField: 'title', + defaultActionType: 'overwrite', + }, + description: { + label: i18n.MAPPING_FIELD_DESC, + validSourceFields: ['title', 'description'], + defaultSourceField: 'description', + defaultActionType: 'overwrite', + }, + comments: { + label: i18n.MAPPING_FIELD_COMMENTS, + validSourceFields: ['comments'], + defaultSourceField: 'comments', + defaultActionType: 'append', + }, + }, }; diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx b/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx index 482808fca53b1..9c3d1c90e67d7 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx @@ -107,4 +107,5 @@ export const JiraConnectorFlyout = withConnectorFlyout({ ConnectorFormComponent: JiraConnectorForm, secretKeys: ['email', 'apiToken'], configKeys: ['projectKey'], + connectorActionTypeId: '.jira', }); diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/logo.svg b/x-pack/plugins/siem/public/lib/connectors/jira/logo.svg index dcd022a8dca18..8560cf7e270c8 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/logo.svg +++ b/x-pack/plugins/siem/public/lib/connectors/jira/logo.svg @@ -1,5 +1,9 @@ - - - - + + + + + + + + diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/translations.ts b/x-pack/plugins/siem/public/lib/connectors/jira/translations.ts index 751aaecdad964..f95663d402604 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/translations.ts +++ b/x-pack/plugins/siem/public/lib/connectors/jira/translations.ts @@ -26,3 +26,10 @@ export const JIRA_PROJECT_KEY_REQUIRED = i18n.translate( defaultMessage: 'Project key is required', } ); + +export const MAPPING_FIELD_SUMMARY = i18n.translate( + 'xpack.siem.case.configureCases.mappingFieldSummary', + { + defaultMessage: 'Summary', + } +); diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/types.ts b/x-pack/plugins/siem/public/lib/connectors/jira/types.ts index 13e4e8f6a289e..d6b8a6cadcb90 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/types.ts +++ b/x-pack/plugins/siem/public/lib/connectors/jira/types.ts @@ -12,6 +12,10 @@ import { JiraSecretConfigurationType, } from '../../../../../actions/server/builtin_action_types/jira/types'; +export { JiraFieldsType } from '../../../../../case/common/api/connectors'; + +export * from '../types'; + export interface JiraActionConnector { config: JiraPublicConfigurationType; secrets: JiraSecretConfigurationType; diff --git a/x-pack/plugins/siem/public/lib/connectors/servicenow/config.ts b/x-pack/plugins/siem/public/lib/connectors/servicenow/config.ts index 7bc1b117b3422..35c677c9574e3 100644 --- a/x-pack/plugins/siem/public/lib/connectors/servicenow/config.ts +++ b/x-pack/plugins/siem/public/lib/connectors/servicenow/config.ts @@ -4,17 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Connector } from '../types'; - -import { SERVICENOW_TITLE } from './translations'; +import { ConnectorConfiguration } from './types'; +import * as i18n from './translations'; import logo from './logo.svg'; -export const connector: Connector = { +export const connector: ConnectorConfiguration = { id: '.servicenow', - name: SERVICENOW_TITLE, + name: i18n.SERVICENOW_TITLE, logo, enabled: true, enabledInConfig: true, enabledInLicense: true, minimumLicenseRequired: 'platinum', + fields: { + short_description: { + label: i18n.MAPPING_FIELD_SHORT_DESC, + validSourceFields: ['title', 'description'], + defaultSourceField: 'title', + defaultActionType: 'overwrite', + }, + description: { + label: i18n.MAPPING_FIELD_DESC, + validSourceFields: ['title', 'description'], + defaultSourceField: 'description', + defaultActionType: 'overwrite', + }, + comments: { + label: i18n.MAPPING_FIELD_COMMENTS, + validSourceFields: ['comments'], + defaultSourceField: 'comments', + defaultActionType: 'append', + }, + }, }; diff --git a/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx b/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx index bcde802e7bd1e..5d5d08dacf90c 100644 --- a/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx @@ -80,4 +80,5 @@ const ServiceNowConnectorForm: React.FC({ ConnectorFormComponent: ServiceNowConnectorForm, secretKeys: ['username', 'password'], + connectorActionTypeId: '.servicenow', }); diff --git a/x-pack/plugins/siem/public/lib/connectors/servicenow/translations.ts b/x-pack/plugins/siem/public/lib/connectors/servicenow/translations.ts index 5dac9eddd1536..39d0ee96513a2 100644 --- a/x-pack/plugins/siem/public/lib/connectors/servicenow/translations.ts +++ b/x-pack/plugins/siem/public/lib/connectors/servicenow/translations.ts @@ -21,3 +21,10 @@ export const SERVICENOW_TITLE = i18n.translate( defaultMessage: 'ServiceNow', } ); + +export const MAPPING_FIELD_SHORT_DESC = i18n.translate( + 'xpack.siem.case.configureCases.mappingFieldShortDescription', + { + defaultMessage: 'Short Description', + } +); diff --git a/x-pack/plugins/siem/public/lib/connectors/servicenow/types.ts b/x-pack/plugins/siem/public/lib/connectors/servicenow/types.ts index b7f0e79eb37e3..43da5624a497b 100644 --- a/x-pack/plugins/siem/public/lib/connectors/servicenow/types.ts +++ b/x-pack/plugins/siem/public/lib/connectors/servicenow/types.ts @@ -12,6 +12,10 @@ import { ServiceNowSecretConfigurationType, } from '../../../../../actions/server/builtin_action_types/servicenow/types'; +export { ServiceNowFieldsType } from '../../../../../case/common/api/connectors'; + +export * from '../types'; + export interface ServiceNowActionConnector { config: ServiceNowPublicConfigurationType; secrets: ServiceNowSecretConfigurationType; diff --git a/x-pack/plugins/siem/public/lib/connectors/translations.ts b/x-pack/plugins/siem/public/lib/connectors/translations.ts index b9c1d0fa2a17f..071fd8ef12645 100644 --- a/x-pack/plugins/siem/public/lib/connectors/translations.ts +++ b/x-pack/plugins/siem/public/lib/connectors/translations.ts @@ -79,3 +79,17 @@ export const EMAIL_REQUIRED = i18n.translate( defaultMessage: 'Email is required', } ); + +export const MAPPING_FIELD_DESC = i18n.translate( + 'xpack.siem.case.configureCases.mappingFieldDescription', + { + defaultMessage: 'Description', + } +); + +export const MAPPING_FIELD_COMMENTS = i18n.translate( + 'xpack.siem.case.configureCases.mappingFieldComments', + { + defaultMessage: 'Comments', + } +); diff --git a/x-pack/plugins/siem/public/lib/connectors/types.ts b/x-pack/plugins/siem/public/lib/connectors/types.ts index 9af60f4995e54..ffb013c347e59 100644 --- a/x-pack/plugins/siem/public/lib/connectors/types.ts +++ b/x-pack/plugins/siem/public/lib/connectors/types.ts @@ -10,8 +10,20 @@ import { ActionType } from '../../../../triggers_actions_ui/public'; import { ExternalIncidentServiceConfiguration } from '../../../../actions/server/builtin_action_types/case/types'; -export interface Connector extends ActionType { +import { ActionType as ThirdPartySupportedActions, CaseField } from '../../../../case/common/api'; + +export { ThirdPartyField as AllThirdPartyFields } from '../../../../case/common/api'; + +export interface ThirdPartyField { + label: string; + validSourceFields: CaseField[]; + defaultSourceField: CaseField; + defaultActionType: ThirdPartySupportedActions; +} + +export interface ConnectorConfiguration extends ActionType { logo: string; + fields: Record; } export interface ActionConnector { @@ -40,6 +52,7 @@ export interface ConnectorFlyoutFormProps { export interface ConnectorFlyoutHOCProps { ConnectorFormComponent: React.FC>; + connectorActionTypeId: string; configKeys?: string[]; secretKeys?: string[]; } diff --git a/x-pack/plugins/siem/public/lib/connectors/utils.ts b/x-pack/plugins/siem/public/lib/connectors/utils.ts index 5b5270ade5a65..169b4758876e8 100644 --- a/x-pack/plugins/siem/public/lib/connectors/utils.ts +++ b/x-pack/plugins/siem/public/lib/connectors/utils.ts @@ -16,10 +16,12 @@ import { ActionConnectorParams, ActionConnectorValidationErrors, Optional, + ThirdPartyField, } from './types'; import { isUrlInvalid } from './validators'; import * as i18n from './translations'; +import { CasesConfigurationMapping } from '../../containers/case/configure/types'; export const createActionType = ({ id, @@ -69,3 +71,15 @@ const ConnectorParamsFields: React.FunctionComponent { return { errors: {} }; }; + +export const createDefaultMapping = ( + fields: Record +): CasesConfigurationMapping[] => + Object.keys(fields).map( + key => + ({ + source: fields[key].defaultSourceField, + target: key, + actionType: fields[key].defaultActionType, + } as CasesConfigurationMapping) + ); diff --git a/x-pack/plugins/siem/public/mock/global_state.ts b/x-pack/plugins/siem/public/mock/global_state.ts index 6678c3043a3da..d0223b7834db0 100644 --- a/x-pack/plugins/siem/public/mock/global_state.ts +++ b/x-pack/plugins/siem/public/mock/global_state.ts @@ -23,6 +23,7 @@ import { DEFAULT_INTERVAL_TYPE, DEFAULT_INTERVAL_VALUE, } from '../../common/constants'; +import { TimelineType } from '../../common/types/timeline'; export const mockGlobalState: State = { app: { @@ -201,6 +202,9 @@ export const mockGlobalState: State = { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, noteIds: [], dateRange: { start: 0, diff --git a/x-pack/plugins/siem/public/mock/timeline_results.ts b/x-pack/plugins/siem/public/mock/timeline_results.ts index edd1c73771829..1af0f533a7ca9 100644 --- a/x-pack/plugins/siem/public/mock/timeline_results.ts +++ b/x-pack/plugins/siem/public/mock/timeline_results.ts @@ -3,6 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { FilterStateStore } from '../../../../../src/plugins/data/common/es_query/filters/meta_filter'; + +import { TimelineType } from '../../common/types/timeline'; import { OpenTimelineResult } from '../components/open_timeline/types'; import { GetAllTimeline, SortFieldTimeline, TimelineResult, Direction } from '../graphql/types'; @@ -10,7 +13,6 @@ import { allTimelinesQuery } from '../containers/timeline/all/index.gql_query'; import { CreateTimelineProps } from '../pages/detection_engine/components/signals/types'; import { TimelineModel } from '../store/timeline/model'; import { timelineDefaults } from '../store/timeline/defaults'; -import { FilterStateStore } from '../../../../../src/plugins/data/common/es_query/filters/meta_filter'; export interface MockedProvidedQuery { request: { query: GetAllTimeline.Query; @@ -168,7 +170,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 1', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -297,7 +299,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 2', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -426,7 +428,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 2', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -555,7 +557,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 3', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -684,7 +686,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 4', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -813,7 +815,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 5', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -942,7 +944,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 6', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -1071,7 +1073,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 7', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -1200,7 +1202,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 7', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -1329,7 +1331,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 7', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -1458,7 +1460,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 7', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -1587,7 +1589,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 7', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -1716,7 +1718,7 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [ 'ZF0W12oB9v5HJNSHwY6L', ], title: 'test 7', - timelineType: null, + timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, created: 1558386787614, @@ -2141,6 +2143,9 @@ export const mockTimelineModel: TimelineModel = { sortDirection: Direction.desc, }, title: 'Test rule', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, version: '1', width: 1100, }; @@ -2164,6 +2169,9 @@ export const mockTimelineResult: TimelineResult = { ], kqlMode: 'filter', title: 'Test rule', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, savedQueryId: null, sort: { columnId: '@timestamp', sortDirection: 'desc' }, version: '1', @@ -2235,6 +2243,9 @@ export const defaultTimelineProps: CreateTimelineProps = { showRowRenderers: true, sort: { columnId: '@timestamp', sortDirection: Direction.desc }, title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, version: null, width: 1100, }, diff --git a/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx b/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx index 31c795c05edd5..2a06fa6eb51ac 100644 --- a/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx @@ -7,14 +7,14 @@ import React from 'react'; import { mount } from 'enzyme'; -import { ServiceNowColumn } from './columns'; +import { ExternalServiceColumn } from './columns'; import { useGetCasesMockState } from '../../../../containers/case/mock'; -describe('ServiceNowColumn ', () => { +describe('ExternalServiceColumn ', () => { it('Not pushed render', () => { const wrapper = mount( - + ); expect( wrapper @@ -25,7 +25,7 @@ describe('ServiceNowColumn ', () => { }); it('Up to date', () => { const wrapper = mount( - + ); expect( wrapper @@ -36,7 +36,7 @@ describe('ServiceNowColumn ', () => { }); it('Needs update', () => { const wrapper = mount( - + ); expect( wrapper diff --git a/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 9c2a7fc07f2d3..9a0460009ffac 100644 --- a/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -150,10 +150,22 @@ export const getCasesColumns = ( }, }, { - name: i18n.SERVICENOW_INCIDENT, + name: i18n.EXTERNAL_INCIDENT, render: (theCase: Case) => { if (theCase.id != null) { - return ; + return ; + } + return getEmptyTagValue(); + }, + }, + { + name: i18n.INCIDENT_MANAGEMENT_SYSTEM, + render: (theCase: Case) => { + if (theCase.externalService != null) { + return renderStringField( + `${theCase.externalService.connectorName}`, + `case-table-column-connector` + ); } return getEmptyTagValue(); }, @@ -168,7 +180,7 @@ interface Props { theCase: Case; } -export const ServiceNowColumn: React.FC = ({ theCase }) => { +export const ExternalServiceColumn: React.FC = ({ theCase }) => { const handleRenderDataToPush = useCallback(() => { const lastCaseUpdate = theCase.updatedAt != null ? new Date(theCase.updatedAt) : null; const lastCasePush = diff --git a/x-pack/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/plugins/siem/public/pages/case/components/all_cases/translations.ts index d3dcfa50ecfa5..d6e044abb8e89 100644 --- a/x-pack/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -46,10 +46,17 @@ export const BULK_ACTIONS = i18n.translate('xpack.siem.case.caseTable.bulkAction defaultMessage: 'Bulk actions', }); -export const SERVICENOW_INCIDENT = i18n.translate('xpack.siem.case.caseTable.snIncident', { - defaultMessage: 'ServiceNow Incident', +export const EXTERNAL_INCIDENT = i18n.translate('xpack.siem.case.caseTable.snIncident', { + defaultMessage: 'External Incident', }); +export const INCIDENT_MANAGEMENT_SYSTEM = i18n.translate( + 'xpack.siem.case.caseTable.incidentSystem', + { + defaultMessage: 'Incident Management System', + } +); + export const SEARCH_PLACEHOLDER = i18n.translate('xpack.siem.case.caseTable.searchPlaceholder', { defaultMessage: 'e.g. case name', }); diff --git a/x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx index 01b9bc42f8e91..14039dc2cbc30 100644 --- a/x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -35,6 +35,8 @@ import { navTabs } from '../../../home/home_navigations'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; import { usePushToService } from '../use_push_to_service'; +import { EditConnector } from '../edit_connector'; +import { useConnectors } from '../../../../containers/case/configure/use_connectors'; interface Props { caseId: string; @@ -67,17 +69,15 @@ export const CaseComponent = React.memo( const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; const search = useGetUrlSearch(navTabs.case); - const [initLoadingData, setInitLoadingData] = useState(true); const { caseUserActions, fetchCaseUserActions, - firstIndexPushToService, + caseServices, hasDataToPush, isLoading: isLoadingUserActions, - lastIndexPushToService, participants, - } = useGetCaseUserActions(caseId); + } = useGetCaseUserActions(caseId, caseData.connectorId); const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({ caseId, }); @@ -100,6 +100,18 @@ export const CaseComponent = React.memo( }); } break; + case 'connectorId': + const connectorId = getTypedPayload(updateValue); + if (connectorId.length > 0) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'connector_id', + updateValue: connectorId, + updateCase: handleUpdateNewCase, + version: caseData.version, + }); + } + break; case 'description': const descriptionUpdate = getTypedPayload(updateValue); if (descriptionUpdate.length > 0) { @@ -147,14 +159,26 @@ export const CaseComponent = React.memo( [updateCase, fetchCaseUserActions] ); + const { loading: isLoadingConnectors, connectors } = useConnectors(); + const caseConnectorName = useMemo( + () => connectors.find(c => c.id === caseData.connectorId)?.name ?? 'none', + [connectors, caseData.connectorId] + ); const { pushButton, pushCallouts } = usePushToService({ + caseConnectorId: caseData.connectorId, + caseConnectorName, + caseServices, caseId: caseData.id, caseStatus: caseData.status, - isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, + connectors, updateCase: handleUpdateCase, userCanCrud, }); + const onSubmitConnector = useCallback( + connectorId => onUpdateField('connectorId', connectorId), + [onUpdateField] + ); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [ onUpdateField, @@ -241,7 +265,7 @@ export const CaseComponent = React.memo( - {pushCallouts != null && pushCallouts} + {!initLoadingData && pushCallouts != null && pushCallouts} {initLoadingData && } @@ -249,12 +273,12 @@ export const CaseComponent = React.memo( <> ( onSubmit={onSubmitTags} isLoading={isLoading && updateKey === 'tags'} /> + diff --git a/x-pack/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/plugins/siem/public/pages/case/components/case_view/translations.ts index 70b8035db5c16..907527a5d8208 100644 --- a/x-pack/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -26,6 +26,21 @@ export const CHANGED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabe defaultMessage: 'changed', }); +export const SELECTED_THIRD_PARTY = (thirdParty: string) => + i18n.translate('xpack.siem.case.caseView.actionLabel.selectedThirdParty', { + values: { + thirdParty, + }, + defaultMessage: 'selected { thirdParty } as incident management system', + }); + +export const REMOVED_THIRD_PARTY = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.removedThirdParty', + { + defaultMessage: 'removed external incident management system', + } +); + export const EDITED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.editedField', { defaultMessage: 'edited', }); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/closure_options.test.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/closure_options.test.tsx index 209dce9aedffc..eaef524b13da8 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/closure_options.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/closure_options.test.tsx @@ -61,7 +61,6 @@ describe('ClosureOptions', () => { test('the closure type is changed successfully', () => { wrapper.find('input[id="close-by-pushing"]').simulate('change'); - expect(onChangeClosureType).toHaveBeenCalled(); expect(onChangeClosureType).toHaveBeenCalledWith('close-by-pushing'); }); }); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors.test.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors.test.tsx index 5fb52c374b482..125a42b126466 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors.test.tsx @@ -69,15 +69,15 @@ describe('Connectors', () => { test('the connector is changed successfully', () => { wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); expect(onChangeConnector).toHaveBeenCalled(); - expect(onChangeConnector).toHaveBeenCalledWith('456'); + expect(onChangeConnector).toHaveBeenCalledWith('servicenow-2'); }); test('the connector is changed successfully to none', () => { onChangeConnector.mockClear(); - const newWrapper = mount(, { + const newWrapper = mount(, { wrappingComponent: TestProviders, }); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.test.tsx index 044108962efc7..6abe4f1ac00ad 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.test.tsx @@ -44,8 +44,14 @@ describe('ConnectorsDropdown', () => { value: 'none', 'data-test-subj': 'dropdown-connector-no-connector', }), - expect.objectContaining({ value: '123', 'data-test-subj': 'dropdown-connector-123' }), - expect.objectContaining({ value: '456', 'data-test-subj': 'dropdown-connector-456' }), + expect.objectContaining({ + value: 'servicenow-1', + 'data-test-subj': 'dropdown-connector-servicenow-1', + }), + expect.objectContaining({ + value: 'servicenow-2', + 'data-test-subj': 'dropdown-connector-servicenow-2', + }), ]) ); }); @@ -77,7 +83,7 @@ describe('ConnectorsDropdown', () => { }); test('it selects the correct connector', () => { - const newWrapper = mount(, { + const newWrapper = mount(, { wrappingComponent: TestProviders, }); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx index d5575f3bac4c8..bfd26d3cf8e00 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx @@ -5,7 +5,7 @@ */ import React, { useMemo } from 'react'; -import { EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import styled from 'styled-components'; import { Connector } from '../../../../containers/case/configure/types'; @@ -24,15 +24,20 @@ const ICON_SIZE = 'm'; const EuiIconExtended = styled(EuiIcon)` margin-right: 13px; + margin-bottom: 0 !important; `; const noConnectorOption = { value: 'none', inputDisplay: ( - <> - - {i18n.NO_CONNECTOR} - + + + + + + {i18n.NO_CONNECTOR} + + ), 'data-test-subj': 'dropdown-connector-no-connector', }; @@ -52,13 +57,19 @@ const ConnectorsDropdownComponent: React.FC = ({ { value: connector.id, inputDisplay: ( - <> - - {connector.name} - + + + + + + + {connector.name} + + + ), 'data-test-subj': `dropdown-connector-${connector.id}`, }, diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping.test.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping.test.tsx index 9ab752bb589c0..498757a34b78d 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping.test.tsx @@ -7,10 +7,12 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; +import { connectorsConfiguration } from '../../../../lib/connectors/config'; +import { createDefaultMapping } from '../../../../lib/connectors/utils'; + import { FieldMapping, FieldMappingProps } from './field_mapping'; import { mapping } from './__mock__'; import { FieldMappingRow } from './field_mapping_row'; -import { defaultMapping } from '../../../../lib/connectors/config'; import { TestProviders } from '../../../../mock'; describe('FieldMappingRow', () => { @@ -20,6 +22,7 @@ describe('FieldMappingRow', () => { disabled: false, mapping, onChangeMapping, + connectorActionTypeId: '.servicenow', }; beforeAll(() => { @@ -66,6 +69,9 @@ describe('FieldMappingRow', () => { wrappingComponent: TestProviders, }); + const selectedConnector = connectorsConfiguration['.servicenow']; + const defaultMapping = createDefaultMapping(selectedConnector.fields); + const rows = newWrapper.find(FieldMappingRow); rows.forEach((row, index) => { expect(row.prop('siemField')).toEqual(defaultMapping[index].source); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx index 2934b1056e29c..41a6fbca3c007 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx @@ -4,53 +4,83 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiFormRow, EuiFlexItem, EuiFlexGroup, EuiSuperSelectOption } from '@elastic/eui'; import styled from 'styled-components'; import { CasesConfigurationMapping, - ThirdPartyField, CaseField, ActionType, + ThirdPartyField, } from '../../../../containers/case/configure/types'; import { FieldMappingRow } from './field_mapping_row'; import * as i18n from './translations'; -import { defaultMapping } from '../../../../lib/connectors/config'; +import { connectorsConfiguration } from '../../../../lib/connectors/config'; import { setActionTypeToMapping, setThirdPartyToMapping } from './utils'; +import { + ThirdPartyField as ConnectorConfigurationThirdPartyField, + AllThirdPartyFields, +} from '../../../../lib/connectors/types'; +import { createDefaultMapping } from '../../../../lib/connectors/utils'; const FieldRowWrapper = styled.div` margin-top: 8px; font-size: 14px; `; -const supportedThirdPartyFields: Array> = [ - { - value: 'not_mapped', - inputDisplay: {i18n.FIELD_MAPPING_FIELD_NOT_MAPPED}, - 'data-test-subj': 'third-party-field-not-mapped', - }, +const actionTypeOptions: Array> = [ { - value: 'short_description', - inputDisplay: {i18n.FIELD_MAPPING_FIELD_SHORT_DESC}, - 'data-test-subj': 'third-party-field-short-description', + value: 'nothing', + inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_NOTHING}, + 'data-test-subj': 'edit-update-option-nothing', }, { - value: 'comments', - inputDisplay: {i18n.FIELD_MAPPING_FIELD_COMMENTS}, - 'data-test-subj': 'third-party-field-comments', + value: 'overwrite', + inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_OVERWRITE}, + 'data-test-subj': 'edit-update-option-overwrite', }, { - value: 'description', - inputDisplay: {i18n.FIELD_MAPPING_FIELD_DESC}, - 'data-test-subj': 'third-party-field-description', + value: 'append', + inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_APPEND}, + 'data-test-subj': 'edit-update-option-append', }, ]; +const getThirdPartyOptions = ( + caseField: CaseField, + thirdPartyFields: Record +): Array> => + (Object.keys(thirdPartyFields) as AllThirdPartyFields[]).reduce< + Array> + >( + (acc, key) => { + if (thirdPartyFields[key].validSourceFields.includes(caseField)) { + return [ + ...acc, + { + value: key, + inputDisplay: {thirdPartyFields[key].label}, + 'data-test-subj': `dropdown-mapping-${key}`, + }, + ]; + } + return acc; + }, + [ + { + value: 'not_mapped', + inputDisplay: i18n.MAPPING_FIELD_NOT_MAPPED, + 'data-test-subj': 'dropdown-mapping-not_mapped', + }, + ] + ); + export interface FieldMappingProps { disabled: boolean; mapping: CasesConfigurationMapping[] | null; + connectorActionTypeId: string; onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void; } @@ -58,6 +88,7 @@ const FieldMappingComponent: React.FC = ({ disabled, mapping, onChangeMapping, + connectorActionTypeId, }) => { const onChangeActionType = useCallback( (caseField: CaseField, newActionType: ActionType) => { @@ -74,6 +105,12 @@ const FieldMappingComponent: React.FC = ({ }, [mapping] ); + + const selectedConnector = connectorsConfiguration[connectorActionTypeId] ?? { fields: {} }; + const defaultMapping = useMemo(() => createDefaultMapping(selectedConnector.fields), [ + selectedConnector.fields, + ]); + return ( <> @@ -92,10 +129,12 @@ const FieldMappingComponent: React.FC = ({ {(mapping ?? defaultMapping).map(item => ( > = [ { @@ -25,15 +25,35 @@ const thirdPartyOptions: Array> = [ }, ]; +const actionTypeOptions: Array> = [ + { + value: 'nothing', + inputDisplay: <>{'Nothing'}, + 'data-test-subj': 'edit-update-option-nothing', + }, + { + value: 'overwrite', + inputDisplay: <>{'Overwrite'}, + 'data-test-subj': 'edit-update-option-overwrite', + }, + { + value: 'append', + inputDisplay: <>{'Append'}, + 'data-test-subj': 'edit-update-option-append', + }, +]; + describe('FieldMappingRow', () => { let wrapper: ReactWrapper; const onChangeActionType = jest.fn(); const onChangeThirdParty = jest.fn(); const props: RowProps = { + id: 'title', disabled: false, siemField: 'title', thirdPartyOptions, + actionTypeOptions, onChangeActionType, onChangeThirdParty, selectedActionType: 'nothing', @@ -47,14 +67,14 @@ describe('FieldMappingRow', () => { test('it renders', () => { expect( wrapper - .find('[data-test-subj="case-configure-third-party-select"]') + .find('[data-test-subj="case-configure-third-party-select-title"]') .first() .exists() ).toBe(true); expect( wrapper - .find('[data-test-subj="case-configure-action-type-select"]') + .find('[data-test-subj="case-configure-action-type-select-title"]') .first() .exists() ).toBe(true); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx index 732a11a58d35a..687b0517326eb 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx @@ -14,45 +14,32 @@ import { } from '@elastic/eui'; import { capitalize } from 'lodash/fp'; -import * as i18n from './translations'; + import { CaseField, ActionType, ThirdPartyField, } from '../../../../containers/case/configure/types'; +import { AllThirdPartyFields } from '../../../../lib/connectors/types'; export interface RowProps { + id: string; disabled: boolean; siemField: CaseField; - thirdPartyOptions: Array>; + thirdPartyOptions: Array>; + actionTypeOptions: Array>; onChangeActionType: (caseField: CaseField, newActionType: ActionType) => void; onChangeThirdParty: (caseField: CaseField, newThirdPartyField: ThirdPartyField) => void; selectedActionType: ActionType; selectedThirdParty: ThirdPartyField; } -const actionTypeOptions: Array> = [ - { - value: 'nothing', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_NOTHING}, - 'data-test-subj': 'edit-update-option-nothing', - }, - { - value: 'overwrite', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_OVERWRITE}, - 'data-test-subj': 'edit-update-option-overwrite', - }, - { - value: 'append', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_APPEND}, - 'data-test-subj': 'edit-update-option-append', - }, -]; - const FieldMappingRowComponent: React.FC = ({ + id, disabled, siemField, thirdPartyOptions, + actionTypeOptions, onChangeActionType, onChangeThirdParty, selectedActionType, @@ -77,7 +64,7 @@ const FieldMappingRowComponent: React.FC = ({ options={thirdPartyOptions} valueOfSelected={selectedThirdParty} onChange={onChangeThirdParty.bind(null, siemField)} - data-test-subj={'case-configure-third-party-select'} + data-test-subj={`case-configure-third-party-select-${id}`} /> @@ -86,7 +73,7 @@ const FieldMappingRowComponent: React.FC = ({ options={actionTypeOptions} valueOfSelected={selectedActionType} onChange={onChangeActionType.bind(null, siemField)} - data-test-subj={'case-configure-action-type-select'} + data-test-subj={`case-configure-action-type-select-${id}`} /> diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx index fde179f3d25fc..0359c1dbdba67 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx @@ -30,7 +30,6 @@ import { useCaseConfigureResponse, useConnectorsResponse, kibanaMockImplementationArgs, - mapping, } from './__mock__'; jest.mock('../../../../lib/kibana'); @@ -140,13 +139,13 @@ describe('ConfigureCases', () => { jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[0].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '123', + connectorId: 'servicenow-1', connectorName: 'unchanged', currentConfiguration: { connectorName: 'unchanged', - connectorId: '123', + connectorId: 'servicenow-1', closureType: 'close-by-user', }, })); @@ -166,7 +165,7 @@ describe('ConfigureCases', () => { expect(wrapper.find(Connectors).prop('connectors')).toEqual(connectors); expect(wrapper.find(Connectors).prop('disabled')).toBe(false); expect(wrapper.find(Connectors).prop('isLoading')).toBe(false); - expect(wrapper.find(Connectors).prop('selectedConnector')).toBe('123'); + expect(wrapper.find(Connectors).prop('selectedConnector')).toBe('servicenow-1'); // ClosureOptions expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(false); @@ -175,6 +174,7 @@ describe('ConfigureCases', () => { // Mapping expect(wrapper.find(Mapping).prop('disabled')).toBe(true); expect(wrapper.find(Mapping).prop('updateConnectorDisabled')).toBe(false); + expect(wrapper.find(Mapping).prop('connectorActionTypeId')).toBe('.servicenow'); expect(wrapper.find(Mapping).prop('mapping')).toEqual( connectors[0].config.casesConfiguration.mapping ); @@ -182,24 +182,12 @@ describe('ConfigureCases', () => { // Flyouts expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(false); expect(wrapper.find(ConnectorAddFlyout).prop('actionTypes')).toEqual([ - { + expect.objectContaining({ id: '.servicenow', - name: 'ServiceNow', - enabled: true, - logo: 'test-file-stub', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', - }, - { + }), + expect.objectContaining({ id: '.jira', - name: 'Jira', - logo: 'test-file-stub', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', - }, + }), ]); expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false); @@ -223,8 +211,6 @@ describe('ConfigureCases', () => { }); // TODO: When mapping is enabled the test.todo should be implemented. - test.todo('the mapping is changed successfully when changing the third party'); - test.todo('the mapping is changed successfully when changing the action type'); test.todo('it disables the update connector button when loading the configuration'); test('it disables correctly when the user cannot crud', () => { @@ -274,13 +260,13 @@ describe('ConfigureCases', () => { test('it disables the buttons of action bar when loading connectors', () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'unchanged', currentConfiguration: { connectorName: 'unchanged', - connectorId: '123', + connectorId: 'servicenow-1', closureType: 'close-by-user', }, })); @@ -335,13 +321,13 @@ describe('ConfigureCases', () => { test('it disables the buttons of action bar when saving configuration', () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'unchanged', currentConfiguration: { connectorName: 'unchanged', - connectorId: '123', + connectorId: 'servicenow-1', closureType: 'close-by-user', }, persistLoading: true, @@ -369,13 +355,13 @@ describe('ConfigureCases', () => { test('it shows the loading spinner when saving configuration', () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'unchanged', currentConfiguration: { connectorName: 'unchanged', - connectorId: '123', + connectorId: 'servicenow-1', closureType: 'close-by-user', }, persistLoading: true, @@ -409,13 +395,13 @@ describe('ConfigureCases', () => { jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'unchanged', currentConfiguration: { connectorName: 'unchanged', - connectorId: '123', + connectorId: 'servicenow-1', closureType: 'close-by-user', }, persistCaseConfigure, @@ -437,7 +423,7 @@ describe('ConfigureCases', () => { expect(persistCaseConfigure).toHaveBeenCalled(); expect(persistCaseConfigure).toHaveBeenCalledWith({ - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'My Connector 2', closureType: 'close-by-user', }); @@ -451,16 +437,17 @@ describe('ConfigureCases', () => { .prop('href') ).toBe(`#/link-to/case${searchURL}`); }); + test('it disables the buttons of action bar when loading configuration', () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'unchanged', currentConfiguration: { connectorName: 'unchanged', - connectorId: '123', + connectorId: 'servicenow-1', closureType: 'close-by-user', }, loading: true, @@ -490,13 +477,13 @@ describe('ConfigureCases', () => { jest.resetAllMocks(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'unchanged', currentConfiguration: { connectorName: 'unchanged', - connectorId: '456', + connectorId: 'servicenow-2', closureType: 'close-by-user', }, })); @@ -530,20 +517,20 @@ describe('ConfigureCases', () => { test('it tracks the changes successfully', () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'unchanged', currentConfiguration: { connectorName: 'unchanged', - connectorId: '123', + connectorId: 'servicenow-1', closureType: 'close-by-pushing', }, })); const wrapper = mount(, { wrappingComponent: TestProviders }); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); wrapper.update(); wrapper.find('input[id="close-by-pushing"]').simulate('change'); wrapper.update(); @@ -558,23 +545,25 @@ describe('ConfigureCases', () => { .text() ).toBe('2 unsaved changes'); }); + test('it tracks the changes successfully when name changes', () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', + connectorId: 'servicenow-2', connectorName: 'nameChange', currentConfiguration: { - connectorId: '123', + connectorId: 'servicenow-1', closureType: 'close-by-pushing', connectorName: 'before', }, })); + const wrapper = mount(, { wrappingComponent: TestProviders }); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); wrapper.update(); wrapper.find('input[id="close-by-pushing"]').simulate('change'); wrapper.update(); @@ -595,7 +584,7 @@ describe('ConfigureCases', () => { // change settings wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); wrapper.update(); wrapper.find('input[id="close-by-pushing"]').simulate('change'); wrapper.update(); @@ -603,7 +592,7 @@ describe('ConfigureCases', () => { // revert back to initial settings wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-123"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-servicenow-1"]').simulate('click'); wrapper.update(); wrapper.find('input[id="close-by-user"]').simulate('change'); wrapper.update(); @@ -617,17 +606,17 @@ describe('ConfigureCases', () => { useCaseConfigureMock .mockImplementationOnce(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', - currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + connectorId: 'servicenow-2', + currentConfiguration: { connectorId: 'servicenow-2', closureType: 'close-by-user' }, })) .mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-pushing', - connectorId: '456', - currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + connectorId: 'servicenow-2', + currentConfiguration: { connectorId: 'servicenow-2', closureType: 'close-by-user' }, })); const wrapper = mount(, { wrappingComponent: TestProviders }); // Change closure type @@ -672,17 +661,17 @@ describe('ConfigureCases', () => { useCaseConfigureMock .mockImplementationOnce(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', - currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + connectorId: 'servicenow-2', + currentConfiguration: { connectorId: 'servicenow-2', closureType: 'close-by-user' }, })) .mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-pushing', - connectorId: '456', - currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + connectorId: 'servicenow-2', + currentConfiguration: { connectorId: 'servicenow-2', closureType: 'close-by-user' }, })); const wrapper = mount(, { wrappingComponent: TestProviders }); @@ -724,22 +713,22 @@ describe('ConfigureCases', () => { useCaseConfigureMock .mockImplementationOnce(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[0].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '123', - currentConfiguration: { connectorId: '123', closureType: 'close-by-user' }, + connectorId: 'servicenow-1', + currentConfiguration: { connectorId: 'servicenow-1', closureType: 'close-by-user' }, })) .mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', - currentConfiguration: { connectorId: '123', closureType: 'close-by-user' }, + connectorId: 'servicenow-2', + currentConfiguration: { connectorId: 'servicenow-1', closureType: 'close-by-user' }, })); const wrapper = mount(, { wrappingComponent: TestProviders }); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); wrapper.update(); expect( @@ -757,17 +746,17 @@ describe('ConfigureCases', () => { useCaseConfigureMock .mockImplementationOnce(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-user', - connectorId: '456', - currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + connectorId: 'servicenow-2', + currentConfiguration: { connectorId: 'servicenow-2', closureType: 'close-by-user' }, })) .mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping, + mapping: connectors[1].config.casesConfiguration.mapping, closureType: 'close-by-pushing', - connectorId: '456', - currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + connectorId: 'servicenow-2', + currentConfiguration: { connectorId: 'servicenow-2', closureType: 'close-by-user' }, })); const wrapper = mount(, { wrappingComponent: TestProviders }); wrapper.find('input[id="close-by-pushing"]').simulate('change'); @@ -788,5 +777,30 @@ describe('ConfigureCases', () => { wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() ).toBeFalsy(); }); + + test('it sets the mapping correctly when changing connector types', () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping: connectors[2].config.casesConfiguration.mapping, + closureType: 'close-by-user', + connectorId: 'jira-1', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: 'servicenow-1', + closureType: 'close-by-user', + }, + persistLoading: false, + })); + + const wrapper = mount(, { wrappingComponent: TestProviders }); + expect( + wrapper.find('button[data-test-subj="case-configure-third-party-select-title"]').text() + ).toBe('Summary'); + }); + + // TODO: When mapping is enabled the test.todo should be implemented. + test.todo('the mapping is changed successfully when changing the third party'); + test.todo('the mapping is changed successfully when changing the action type'); }); }); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/index.tsx index 66eef9e3ec7bf..40def5231a304 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState, Dispatch, SetStateAction } from 'react'; +import React, { useCallback, useEffect, useState, Dispatch, SetStateAction, useMemo } from 'react'; import styled, { css } from 'styled-components'; import { @@ -64,7 +64,7 @@ interface ConfigureCasesComponentProps { const ConfigureCasesComponent: React.FC = ({ userCanCrud }) => { const search = useGetUrlSearch(navTabs.case); - const { http, triggers_actions_ui, notifications, application } = useKibana().services; + const { http, triggers_actions_ui, notifications, application, docLinks } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); @@ -200,6 +200,11 @@ const ConfigureCasesComponent: React.FC = ({ userC currentConfiguration.closureType, ]); + const connectorActionTypeId = useMemo( + () => connectors.find(c => c.id === connectorId)?.actionTypeId ?? '.none', + [connectorId, connectors] + ); + return ( {!connectorIsValid && ( @@ -236,6 +241,7 @@ const ConfigureCasesComponent: React.FC = ({ userC disabled updateConnectorDisabled={updateConnectorDisabled || !userCanCrud} mapping={mapping} + connectorActionTypeId={connectorActionTypeId} onChangeMapping={setMapping} setEditFlyoutVisibility={onClickUpdateConnector} /> @@ -291,6 +297,7 @@ const ConfigureCasesComponent: React.FC = ({ userC toastNotifications: notifications.toasts, capabilities: application.capabilities, reloadConnectors, + docLinks, }} > { updateConnectorDisabled: false, onChangeMapping, setEditFlyoutVisibility, + connectorActionTypeId: '.servicenow', }; - beforeAll(() => { + beforeEach(() => { + jest.clearAllMocks(); wrapper = mount(, { wrappingComponent: TestProviders }); }); - test('it shows mapping form group', () => { - expect( - wrapper - .find('[data-test-subj="case-mapping-form-group"]') - .first() - .exists() - ).toBe(true); + afterEach(() => { + wrapper.unmount(); }); - test('it shows mapping form row', () => { - expect( + describe('Common', () => { + test('it shows mapping form group', () => { + expect( + wrapper + .find('[data-test-subj="case-mapping-form-group"]') + .first() + .exists() + ).toBe(true); + }); + + test('it shows mapping form row', () => { + expect( + wrapper + .find('[data-test-subj="case-mapping-form-row"]') + .first() + .exists() + ).toBe(true); + }); + + test('it shows the update button', () => { + expect( + wrapper + .find('[data-test-subj="case-mapping-update-connector-button"]') + .first() + .exists() + ).toBe(true); + }); + + test('it shows the field mapping', () => { + expect( + wrapper + .find('[data-test-subj="case-mapping-field"]') + .first() + .exists() + ).toBe(true); + }); + + test('it updates thirdParty correctly', () => { wrapper - .find('[data-test-subj="case-mapping-form-row"]') - .first() - .exists() - ).toBe(true); - }); + .find('button[data-test-subj="case-configure-third-party-select-title"]') + .simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-mapping-description"]').simulate('click'); + wrapper.update(); - test('it shows the update button', () => { - expect( + expect(onChangeMapping).toHaveBeenCalledWith([ + { source: 'title', target: 'description', actionType: 'overwrite' }, + { source: 'description', target: 'not_mapped', actionType: 'append' }, + { source: 'comments', target: 'comments', actionType: 'append' }, + ]); + }); + + test('it updates actionType correctly', () => { wrapper - .find('[data-test-subj="case-mapping-update-connector-button"]') - .first() - .exists() - ).toBe(true); - }); + .find('button[data-test-subj="case-configure-action-type-select-title"]') + .simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="edit-update-option-nothing"]').simulate('click'); + wrapper.update(); + + expect(onChangeMapping).toHaveBeenCalledWith([ + { source: 'title', target: 'short_description', actionType: 'nothing' }, + { source: 'description', target: 'description', actionType: 'append' }, + { source: 'comments', target: 'comments', actionType: 'append' }, + ]); + }); - test('it shows the field mapping', () => { - expect( + test('it shows the correct action types', () => { wrapper - .find('[data-test-subj="case-mapping-field"]') - .first() - .exists() - ).toBe(true); + .find('button[data-test-subj="case-configure-action-type-select-title"]') + .simulate('click'); + wrapper.update(); + expect( + wrapper + .find('button[data-test-subj="edit-update-option-nothing"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="edit-update-option-overwrite"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="edit-update-option-append"]') + .first() + .exists() + ).toBeTruthy(); + }); + }); + + describe('Connectors', () => { + describe('ServiceNow', () => { + test('it shows the correct thirdParty fields for title', () => { + wrapper + .find('button[data-test-subj="case-configure-third-party-select-title"]') + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-short_description"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-description"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-not_mapped"]') + .first() + .exists() + ).toBeTruthy(); + }); + + test('it shows the correct thirdParty fields for description', () => { + wrapper + .find('button[data-test-subj="case-configure-third-party-select-description"]') + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-short_description"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-description"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-not_mapped"]') + .first() + .exists() + ).toBeTruthy(); + }); + + test('it shows the correct thirdParty fields for comments', () => { + wrapper + .find('button[data-test-subj="case-configure-third-party-select-comments"]') + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-comments"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-not_mapped"]') + .first() + .exists() + ).toBeTruthy(); + }); + }); + + describe('Jira', () => { + beforeEach(() => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + }); + + test('it shows the correct thirdParty fields for title', () => { + wrapper + .find('button[data-test-subj="case-configure-third-party-select-title"]') + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-summary"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-description"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-not_mapped"]') + .first() + .exists() + ).toBeTruthy(); + }); + + test('it shows the correct thirdParty fields for description', () => { + wrapper + .find('button[data-test-subj="case-configure-third-party-select-description"]') + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-summary"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-description"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-not_mapped"]') + .first() + .exists() + ).toBeTruthy(); + }); + + test('it shows the correct thirdParty fields for comments', () => { + wrapper + .find('button[data-test-subj="case-configure-third-party-select-comments"]') + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-comments"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('button[data-test-subj="dropdown-mapping-not_mapped"]') + .first() + .exists() + ).toBeTruthy(); + }); + }); }); }); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx index 7340a49f6d0bb..acbcdac68a134 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx @@ -24,6 +24,7 @@ export interface MappingProps { disabled: boolean; updateConnectorDisabled: boolean; mapping: CasesConfigurationMapping[] | null; + connectorActionTypeId: string; onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void; setEditFlyoutVisibility: () => void; } @@ -39,6 +40,7 @@ const MappingComponent: React.FC = ({ mapping, onChangeMapping, setEditFlyoutVisibility, + connectorActionTypeId, }) => { return ( = ({ { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + useEffect(() => { + field.setValue(defaultValue); + }, [defaultValue]); + + const handleContentChange = useCallback( + (newContent: string) => { + field.setValue(newContent); + }, + [field] + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.test.tsx b/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.test.tsx new file mode 100644 index 0000000000000..29776360b72da --- /dev/null +++ b/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.test.tsx @@ -0,0 +1,154 @@ +/* + * 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 { mount } from 'enzyme'; + +import { EditConnector } from './index'; +import { getFormMock, useFormMock } from '../__mock__/form'; +import { TestProviders } from '../../../../mock'; +import { connectorsMock } from '../../../../containers/case/configure/mock'; +import { wait } from '../../../../lib/helpers'; +import { act } from 'react-dom/test-utils'; +jest.mock( + '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' +); +const onSubmit = jest.fn(); +const defaultProps = { + connectors: connectorsMock, + disabled: false, + isLoading: false, + onSubmit, + selectedConnector: 'none', +}; + +describe('EditConnector ', () => { + const sampleConnector = '123'; + const formHookMock = getFormMock({ connector: sampleConnector }); + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + useFormMock.mockImplementation(() => ({ form: formHookMock })); + }); + it('Renders no connector, and then edit', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="dropdown-connectors"]`) + .last() + .prop('disabled') + ).toBeTruthy(); + + expect( + wrapper + .find(`span[data-test-subj="dropdown-connector-no-connector"]`) + .last() + .exists() + ).toBeTruthy(); + wrapper + .find(`[data-test-subj="connector-edit-button"]`) + .last() + .simulate('click'); + + expect( + wrapper + .find(`[data-test-subj="edit-connectors-submit"]`) + .last() + .exists() + ).toBeTruthy(); + + expect( + wrapper + .find(`[data-test-subj="dropdown-connectors"]`) + .last() + .prop('disabled') + ).toBeFalsy(); + }); + it('Edit external service on submit', async () => { + const wrapper = mount( + + + + ); + wrapper + .find(`[data-test-subj="connector-edit-button"]`) + .last() + .simulate('click'); + expect( + wrapper + .find(`[data-test-subj="edit-connectors-submit"]`) + .last() + .exists() + ).toBeTruthy(); + await act(async () => { + wrapper + .find(`[data-test-subj="edit-connectors-submit"]`) + .last() + .simulate('click'); + await wait(); + expect(onSubmit).toBeCalledWith(sampleConnector); + }); + }); + it('Resets selector on cancel', async () => { + const props = { + ...defaultProps, + }; + const wrapper = mount( + + + + ); + wrapper + .find(`[data-test-subj="connector-edit-button"]`) + .last() + .simulate('click'); + await act(async () => { + wrapper + .find(`[data-test-subj="edit-connectors-cancel"]`) + .last() + .simulate('click'); + await wait(); + wrapper.update(); + expect(formHookMock.setFieldValue).toBeCalledWith( + 'connector', + defaultProps.selectedConnector + ); + }); + }); + it('Renders disabled button', () => { + const props = { ...defaultProps, disabled: true }; + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-test-subj="connector-edit-button"]`) + .last() + .prop('disabled') + ).toBeTruthy(); + }); + it('Renders loading spinner', () => { + const props = { ...defaultProps, isLoading: true }; + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-test-subj="connector-loading"]`) + .last() + .exists() + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.tsx b/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.tsx new file mode 100644 index 0000000000000..83be8b5ad7e5a --- /dev/null +++ b/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiText, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiLoadingSpinner, +} from '@elastic/eui'; +import styled, { css } from 'styled-components'; +import * as i18n from '../../translations'; +import { Form, UseField, useForm } from '../../../../shared_imports'; +import { schema } from './schema'; +import { ConnectorSelector } from '../connector_selector/form'; +import { Connector } from '../../../../../../case/common/api/cases'; + +interface EditConnectorProps { + connectors: Connector[]; + disabled?: boolean; + isLoading: boolean; + onSubmit: (a: string[]) => void; + selectedConnector: string; +} + +const MyFlexGroup = styled(EuiFlexGroup)` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSizeM}; + p { + font-size: ${theme.eui.euiSizeM}; + } + `} +`; + +export const EditConnector = React.memo( + ({ + connectors, + disabled = false, + isLoading, + onSubmit, + selectedConnector, + }: EditConnectorProps) => { + const { form } = useForm({ + defaultValue: { connectors }, + options: { stripEmptyFields: false }, + schema, + }); + const [isEditConnector, setIsEditConnector] = useState(false); + const handleOnClick = useCallback(() => { + setIsEditConnector(true); + }, []); + + const onCancelConnector = useCallback(() => { + form.setFieldValue('connector', selectedConnector); + setIsEditConnector(false); + }, [form, selectedConnector]); + + const onSubmitConnector = useCallback(async () => { + const { isValid, data: newData } = await form.submit(); + if (isValid && newData.connector) { + onSubmit(newData.connector); + setIsEditConnector(false); + } + }, [form, onSubmit]); + return ( + + + +

{i18n.CONNECTORS}

+
+ {isLoading && } + {!isLoading && ( + + + + )} +
+ + + + +
+ + + + + +
+
+ {isEditConnector && ( + + + + + {i18n.SAVE} + + + + + {i18n.CANCEL} + + + + + )} +
+
+
+ ); + } +); + +EditConnector.displayName = 'EditConnector'; diff --git a/x-pack/plugins/siem/public/pages/case/components/edit_connector/schema.tsx b/x-pack/plugins/siem/public/pages/case/components/edit_connector/schema.tsx new file mode 100644 index 0000000000000..4b9008839e695 --- /dev/null +++ b/x-pack/plugins/siem/public/pages/case/components/edit_connector/schema.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FormSchema } from '../../../../shared_imports'; + +export const schema: FormSchema = { + connector: { + defaultValue: 'none', + }, +}; diff --git a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx index d428d9988ae39..b09c7d00140c3 100644 --- a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx @@ -9,11 +9,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { usePushToService, ReturnUsePushToService, UsePushToService } from './'; import { TestProviders } from '../../../../mock'; import { usePostPushToService } from '../../../../containers/case/use_post_push_to_service'; -import { ClosureType } from '../../../../../../case/common/api/cases'; +import { basicPush, actionLicenses } from '../../../../containers/case/mock'; import * as i18n from './translations'; import { useGetActionLicense } from '../../../../containers/case/use_get_action_license'; import { getKibanaConfigError, getLicenseError } from './helpers'; -import * as api from '../../../../containers/case/configure/api'; +import { connectorsMock } from '../../../../containers/case/configure/mock'; jest.mock('../../../../containers/case/use_get_action_license'); jest.mock('../../../../containers/case/use_post_push_to_service'); jest.mock('../../../../containers/case/configure/api'); @@ -26,28 +26,25 @@ describe('usePushToService', () => { isLoading: false, postPushToService, }; - const closureType: ClosureType = 'close-by-user'; - const mockConnector = { - connectorId: 'c00l', - connectorName: 'name', + const mockConnector = connectorsMock[0]; + const actionLicense = actionLicenses[0]; + const caseServices = { + '123': { + ...basicPush, + firstPushIndex: 0, + lastPushIndex: 0, + hasDataToPush: true, + }, }; - const mockCaseConfigure = { - ...mockConnector, - createdAt: 'string', - createdBy: {}, - closureType, - updatedAt: 'string', - updatedBy: {}, - version: 'string', - }; - const getConfigureMock = jest.spyOn(api, 'getCaseConfigure'); - const actionLicense = { - id: '.servicenow', - name: 'ServiceNow', - minimumLicenseRequired: 'platinum', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, + const defaultArgs = { + caseConnectorId: mockConnector.id, + caseConnectorName: mockConnector.name, + caseId, + caseServices, + caseStatus: 'open', + connectors: connectorsMock, + updateCase, + userCanCrud: true, }; beforeEach(() => { jest.resetAllMocks(); @@ -56,28 +53,24 @@ describe('usePushToService', () => { isLoading: false, actionLicense, })); - getConfigureMock.mockImplementation(() => Promise.resolve(mockCaseConfigure)); }); it('push case button posts the push with correct args', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( - () => - usePushToService({ - caseId, - caseStatus: 'open', - isNew: false, - updateCase, - userCanCrud: true, - }), + () => usePushToService(defaultArgs), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); - expect(getConfigureMock).toBeCalled(); result.current.pushButton.props.children.props.onClick(); - expect(postPushToService).toBeCalledWith({ ...mockConnector, caseId, updateCase }); + expect(postPushToService).toBeCalledWith({ + caseId, + caseServices, + connectorId: mockConnector.id, + connectorName: mockConnector.name, + updateCase, + }); expect(result.current.pushCallouts).toBeNull(); }); }); @@ -91,20 +84,12 @@ describe('usePushToService', () => { })); await act(async () => { const { result, waitForNextUpdate } = renderHook( - () => - usePushToService({ - caseId, - caseStatus: 'open', - isNew: false, - updateCase, - userCanCrud: true, - }), + () => usePushToService(defaultArgs), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); expect(errorsMsg[0].title).toEqual(getLicenseError().title); @@ -120,48 +105,30 @@ describe('usePushToService', () => { })); await act(async () => { const { result, waitForNextUpdate } = renderHook( - () => - usePushToService({ - caseId, - caseStatus: 'open', - isNew: false, - updateCase, - userCanCrud: true, - }), + () => usePushToService(defaultArgs), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); expect(errorsMsg[0].title).toEqual(getKibanaConfigError().title); }); }); it('Displays message when user does not have a connector configured', async () => { - getConfigureMock.mockImplementation(() => - Promise.resolve({ - ...mockCaseConfigure, - connectorId: 'none', - }) - ); await act(async () => { const { result, waitForNextUpdate } = renderHook( () => usePushToService({ - caseId, - caseStatus: 'open', - isNew: false, - updateCase, - userCanCrud: true, + ...defaultArgs, + caseConnectorId: 'none', }), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); @@ -172,18 +139,14 @@ describe('usePushToService', () => { const { result, waitForNextUpdate } = renderHook( () => usePushToService({ - caseId, + ...defaultArgs, caseStatus: 'closed', - isNew: false, - updateCase, - userCanCrud: true, }), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE); diff --git a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx index 6109fd05096b9..7f3a951339ef1 100644 --- a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx @@ -8,7 +8,6 @@ import { EuiButton, EuiLink, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useMemo } from 'react'; -import { useCaseConfigure } from '../../../../containers/case/configure/use_configure'; import { Case } from '../../../../containers/case/types'; import { useGetActionLicense } from '../../../../containers/case/use_get_action_license'; import { usePostPushToService } from '../../../../containers/case/use_post_push_to_service'; @@ -18,11 +17,16 @@ import { navTabs } from '../../../home/home_navigations'; import { CaseCallOut } from '../callout'; import { getLicenseError, getKibanaConfigError } from './helpers'; import * as i18n from './translations'; +import { Connector } from '../../../../../../case/common/api/cases'; +import { CaseServices } from '../../../../containers/case/use_get_case_user_actions'; export interface UsePushToService { caseId: string; caseStatus: string; - isNew: boolean; + caseConnectorId: string; + caseConnectorName: string; + caseServices: CaseServices; + connectors: Connector[]; updateCase: (newCase: Case) => void; userCanCrud: boolean; } @@ -33,9 +37,12 @@ export interface ReturnUsePushToService { } export const usePushToService = ({ + caseConnectorId, + caseConnectorName, caseId, + caseServices, caseStatus, - isNew, + connectors, updateCase, userCanCrud, }: UsePushToService): ReturnUsePushToService => { @@ -43,31 +50,30 @@ export const usePushToService = ({ const { isLoading, postPushToService } = usePostPushToService(); - const { connectorId, connectorName, loading: loadingCaseConfigure } = useCaseConfigure(); - const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); const handlePushToService = useCallback(() => { - if (connectorId != null) { + if (caseConnectorId != null && caseConnectorId !== 'none') { postPushToService({ caseId, - connectorId, - connectorName, + caseServices, + connectorId: caseConnectorId, + connectorName: caseConnectorName, updateCase, }); } - }, [caseId, connectorId, connectorName, postPushToService, updateCase]); + }, [caseId, caseServices, caseConnectorId, caseConnectorName, postPushToService, updateCase]); const errorsMsg = useMemo(() => { let errors: Array<{ title: string; description: JSX.Element }> = []; if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } - if (connectorId === 'none' && !loadingCaseConfigure && !loadingLicense) { + if (connectors.length === 0 && !loadingLicense) { errors = [ ...errors, { - title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, + title: i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE, description: ( + ), + }, + ]; } if (caseStatus === 'closed') { errors = [ @@ -102,40 +121,36 @@ export const usePushToService = ({ errors = [...errors, getKibanaConfigError()]; } return errors; - }, [actionLicense, caseStatus, connectorId, loadingCaseConfigure, loadingLicense, urlSearch]); + }, [actionLicense, caseStatus, connectors.length, caseConnectorId, loadingLicense, urlSearch]); - const pushToServiceButton = useMemo( - () => ( + const pushToServiceButton = useMemo(() => { + return ( 0 || - !userCanCrud - } + disabled={isLoading || loadingLicense || errorsMsg.length > 0 || !userCanCrud} isLoading={isLoading} > - {isNew ? i18n.PUSH_SERVICENOW : i18n.UPDATE_PUSH_SERVICENOW} + {caseServices[caseConnectorId] + ? i18n.UPDATE_THIRD(caseConnectorName) + : i18n.PUSH_THIRD(caseConnectorName)} - ), - [ - isNew, - handlePushToService, - isLoading, - loadingLicense, - loadingCaseConfigure, - errorsMsg, - userCanCrud, - ] - ); + ); + }, [ + caseConnectorId, + caseConnectorName, + connectors, + errorsMsg, + handlePushToService, + isLoading, + loadingLicense, + userCanCrud, + ]); - const objToReturn = useMemo( - () => ({ + const objToReturn = useMemo(() => { + return { pushButton: errorsMsg.length > 0 ? ( 0 ? ( ) : null, - }), - [errorsMsg, pushToServiceButton] - ); + }; + }, [errorsMsg, pushToServiceButton]); + return objToReturn; }; diff --git a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/translations.ts b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/translations.ts index 14bdb0c69712c..2a36fcf8a6bc4 100644 --- a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/translations.ts @@ -12,22 +12,41 @@ export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( defaultMessage: 'To send cases to external systems, you need to:', } ); +export const PUSH_THIRD = (thirdParty: string) => { + if (thirdParty === 'none') { + return i18n.translate('xpack.siem.case.caseView.pushThirdPartyIncident', { + defaultMessage: 'Push as third party incident', + }); + } + return i18n.translate('xpack.siem.case.caseView.pushNamedIncident', { + values: { thirdParty }, + defaultMessage: 'Push as { thirdParty } incident', + }); +}; -export const PUSH_SERVICENOW = i18n.translate('xpack.siem.case.caseView.pushAsServicenowIncident', { - defaultMessage: 'Push as ServiceNow incident', -}); +export const UPDATE_THIRD = (thirdParty: string) => { + if (thirdParty === 'none') { + return i18n.translate('xpack.siem.case.caseView.updateThirdPartyIncident', { + defaultMessage: 'Update third party incident', + }); + } + return i18n.translate('xpack.siem.case.caseView.updateNamedIncident', { + values: { thirdParty }, + defaultMessage: 'Update { thirdParty } incident', + }); +}; -export const UPDATE_PUSH_SERVICENOW = i18n.translate( - 'xpack.siem.case.caseView.updatePushAsServicenowIncident', +export const PUSH_DISABLE_BY_NO_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByNoConfigTitle', { - defaultMessage: 'Update ServiceNow incident', + defaultMessage: 'Configure external connector', } ); export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle', { - defaultMessage: 'Configure external connector', + defaultMessage: 'Select external connector', } ); diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx index e34981286bc81..6e7c2979f80bb 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx @@ -5,19 +5,21 @@ */ import React from 'react'; -import { getUserAction } from '../../../../containers/case/mock'; +import { basicPush, getUserAction } from '../../../../containers/case/mock'; import { getLabelTitle } from './helpers'; import * as i18n from '../case_view/translations'; import { mount } from 'enzyme'; +import { connectorsMock } from '../../../../containers/case/configure/mock'; describe('User action tree helpers', () => { + const connectors = connectorsMock; it('label title generated for update tags', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'tags', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); const wrapper = mount(<>{result}); @@ -39,9 +41,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'title', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual( @@ -54,9 +56,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['description'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'description', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); @@ -65,9 +67,9 @@ describe('User action tree helpers', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'status', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); @@ -76,9 +78,9 @@ describe('User action tree helpers', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'status', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); @@ -87,9 +89,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['comment'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'comment', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); @@ -98,9 +100,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'pushed', - firstIndexPushToService: 0, - index: 0, + firstPush: true, }); const wrapper = mount(<>{result}); @@ -109,7 +111,7 @@ describe('User action tree helpers', () => { .find(`[data-test-subj="pushed-label"]`) .first() .text() - ).toEqual(i18n.PUSHED_NEW_INCIDENT); + ).toEqual(`${i18n.PUSHED_NEW_INCIDENT} ${basicPush.connectorName}`); expect( wrapper .find(`[data-test-subj="pushed-value"]`) @@ -121,9 +123,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'pushed', - firstIndexPushToService: 0, - index: 1, + firstPush: false, }); const wrapper = mount(<>{result}); @@ -132,7 +134,7 @@ describe('User action tree helpers', () => { .find(`[data-test-subj="pushed-label"]`) .first() .text() - ).toEqual(i18n.UPDATE_INCIDENT); + ).toEqual(`${i18n.UPDATE_INCIDENT} ${basicPush.connectorName}`); expect( wrapper .find(`[data-test-subj="pushed-value"]`) @@ -140,4 +142,28 @@ describe('User action tree helpers', () => { .prop('href') ).toEqual(JSON.parse(action.newValue).external_url); }); + it('label title generated for update connector', () => { + const action = getUserAction(['connector_id'], 'update'); + const result: string | JSX.Element = getLabelTitle({ + action, + connectors, + field: 'tags', + firstPush: false, + }); + + const wrapper = mount(<>{result}); + expect( + wrapper + .find(`[data-test-subj="ua-tags-label"]`) + .first() + .text() + ).toEqual(` ${i18n.TAGS.toLowerCase()}`); + + expect( + wrapper + .find(`[data-test-subj="ua-tag"]`) + .first() + .text() + ).toEqual(action.newValue); + }); }); diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx index 96f87c9082945..285fa3c58c18a 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -7,24 +7,29 @@ import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; import React from 'react'; -import { CaseFullExternalService } from '../../../../../../case/common/api'; +import { CaseFullExternalService, Connector } from '../../../../../../case/common/api'; import { CaseUserActions } from '../../../../containers/case/types'; import * as i18n from '../case_view/translations'; interface LabelTitle { action: CaseUserActions; + connectors: Connector[]; field: string; - firstIndexPushToService: number; - index: number; + firstPush: boolean; } -export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: LabelTitle) => { +export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTitle) => { if (field === 'tags') { return getTagsLabelTitle(action); } else if (field === 'title' && action.action === 'update') { return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ action.newValue }"`; + } else if (field === 'connector_id' && action.action === 'update') { + const newConnector = connectors.find(c => c.id === action.newValue); + return action.newValue != null && action.newValue !== 'none' && newConnector != null + ? i18n.SELECTED_THIRD_PARTY(newConnector.name) + : i18n.REMOVED_THIRD_PARTY; } else if (field === 'description' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; } else if (field === 'status' && action.action === 'update') { @@ -34,7 +39,7 @@ export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: } else if (field === 'comment' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) { - return getPushedServiceLabelTitle(action, firstIndexPushToService, index); + return getPushedServiceLabelTitle(action, firstPush); } return ''; }; @@ -56,20 +61,18 @@ const getTagsLabelTitle = (action: CaseUserActions) => ( ); -const getPushedServiceLabelTitle = ( - action: CaseUserActions, - firstIndexPushToService: number, - index: number -) => { +const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; return ( - {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} + {`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${ + pushedVal?.connector_name + }`} - {pushedVal?.connector_name} {pushedVal?.external_title} + {pushedVal?.external_title} diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx index ff402e8ea1c8b..736974545a1df 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx @@ -10,7 +10,7 @@ import { mount } from 'enzyme'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { getFormMock, useFormMock } from '../__mock__/form'; import { useUpdateComment } from '../../../../containers/case/use_update_comment'; -import { basicCase, getUserAction } from '../../../../containers/case/mock'; +import { basicCase, basicPush, getUserAction } from '../../../../containers/case/mock'; import { UserActionTree } from './'; import { TestProviders } from '../../../../mock'; import { wait } from '../../../../lib/helpers'; @@ -20,16 +20,16 @@ const fetchUserActions = jest.fn(); const onUpdateField = jest.fn(); const updateCase = jest.fn(); const defaultProps = { - data: basicCase, + caseServices: {}, caseUserActions: [], - firstIndexPushToService: -1, + connectors: [], + data: basicCase, + fetchUserActions, isLoadingDescription: false, isLoadingUserActions: false, - lastIndexPushToService: -1, - userCanCrud: true, - fetchUserActions, onUpdateField, updateCase, + userCanCrud: true, }; const useUpdateCommentMock = useUpdateComment as jest.Mock; jest.mock('../../../../containers/case/use_update_comment'); @@ -76,13 +76,20 @@ describe('UserActionTree ', () => { }); it('Renders service now update line with top and bottom when push is required', () => { const ourActions = [ - getUserAction(['comment'], 'push-to-service'), + getUserAction(['pushed'], 'push-to-service'), getUserAction(['comment'], 'update'), ]; const props = { ...defaultProps, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 0, + lastPushIndex: 0, + hasDataToPush: true, + }, + }, caseUserActions: ourActions, - lastIndexPushToService: 0, }; const wrapper = mount( @@ -95,11 +102,18 @@ describe('UserActionTree ', () => { expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy(); }); it('Renders service now update line with top only when push is up to date', () => { - const ourActions = [getUserAction(['comment'], 'push-to-service')]; + const ourActions = [getUserAction(['pushed'], 'push-to-service')]; const props = { ...defaultProps, caseUserActions: ourActions, - lastIndexPushToService: 0, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 0, + lastPushIndex: 0, + hasDataToPush: false, + }, + }, }; const wrapper = mount( diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index d1e8eb3f6306b..80d2c20631432 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -18,15 +18,18 @@ import { AddComment } from '../add_comment'; import { getLabelTitle } from './helpers'; import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; +import { Connector } from '../../../../../../case/common/api/cases'; +import { CaseServices } from '../../../../containers/case/use_get_case_user_actions'; +import { parseString } from '../../../../containers/case/utils'; export interface UserActionTreeProps { - data: Case; + caseServices: CaseServices; caseUserActions: CaseUserActions[]; + connectors: Connector[]; + data: Case; fetchUserActions: () => void; - firstIndexPushToService: number; isLoadingDescription: boolean; isLoadingUserActions: boolean; - lastIndexPushToService: number; onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; updateCase: (newCase: Case) => void; userCanCrud: boolean; @@ -42,12 +45,12 @@ const NEW_ID = 'newComment'; export const UserActionTree = React.memo( ({ data: caseData, + caseServices, caseUserActions, + connectors, fetchUserActions, - firstIndexPushToService, isLoadingDescription, isLoadingUserActions, - lastIndexPushToService, onUpdateField, updateCase, userCanCrud, @@ -223,16 +226,30 @@ export const UserActionTree = React.memo( } if (action.actionField.length === 1) { const myField = action.actionField[0]; + const parsedValue = parseString(`${action.newValue}`); + const { firstPush, parsedConnectorId, parsedConnectorName } = + parsedValue != null + ? { + firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, + parsedConnectorId: parsedValue.connector_id, + parsedConnectorName: parsedValue.connector_name, + } + : { + firstPush: false, + parsedConnectorId: 'none', + parsedConnectorName: 'none', + }; const labelTitle: string | JSX.Element = getLabelTitle({ action, field: myField, - firstIndexPushToService, - index, + firstPush, + connectors, }); return ( diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/translations.ts b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/translations.ts index 066145f7762c9..e655329562d41 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/translations.ts @@ -8,19 +8,17 @@ import { i18n } from '@kbn/i18n'; export * from '../case_view/translations'; -export const ALREADY_PUSHED_TO_SERVICE = i18n.translate( - 'xpack.siem.case.caseView.alreadyPushedToService', - { - defaultMessage: 'Already pushed to Service Now incident', - } -); +export const ALREADY_PUSHED_TO_SERVICE = (externalService: string) => + i18n.translate('xpack.siem.case.caseView.alreadyPushedToExternalService', { + values: { externalService }, + defaultMessage: 'Already pushed to { externalService } incident', + }); -export const REQUIRED_UPDATE_TO_SERVICE = i18n.translate( - 'xpack.siem.case.caseView.requiredUpdateToService', - { - defaultMessage: 'Requires update to ServiceNow incident', - } -); +export const REQUIRED_UPDATE_TO_SERVICE = (externalService: string) => + i18n.translate('xpack.siem.case.caseView.requiredUpdateToExternalService', { + values: { externalService }, + defaultMessage: 'Requires update to { externalService } incident', + }); export const COPY_REFERENCE_LINK = i18n.translate('xpack.siem.case.caseView.copyCommentLinkAria', { defaultMessage: 'Copy reference link', diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 0acd0623f9413..eeb728aa7d1df 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -20,6 +20,7 @@ import { UserActionTitle } from './user_action_title'; import * as i18n from './translations'; interface UserActionItemProps { + caseConnectorName?: string; createdAt: string; 'data-test-subj'?: string; disabled: boolean; @@ -85,6 +86,7 @@ export const UserActionItemContainer = styled(EuiFlexGroup)` `; const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` + flex-grow: 0; ${({ theme, showoutline }) => showoutline === 'true' ? ` @@ -111,6 +113,7 @@ const PushedInfoContainer = styled.div` `; export const UserActionItem = ({ + caseConnectorName, createdAt, disabled, 'data-test-subj': dataTestSubj, @@ -177,14 +180,14 @@ export const UserActionItem = ({ - {i18n.ALREADY_PUSHED_TO_SERVICE} + {i18n.ALREADY_PUSHED_TO_SERVICE(`${caseConnectorName}`)} {showBottomFooter && ( - {i18n.REQUIRED_UPDATE_TO_SERVICE} + {i18n.REQUIRED_UPDATE_TO_SERVICE(`${caseConnectorName}`)} )} diff --git a/x-pack/plugins/siem/public/pages/case/translations.ts b/x-pack/plugins/siem/public/pages/case/translations.ts index 097b8220156e2..782ba9d9f32db 100644 --- a/x-pack/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/translations.ts @@ -195,3 +195,11 @@ export const GO_TO_DOCUMENTATION = i18n.translate( defaultMessage: 'View documentation', } ); + +export const CONNECTORS = i18n.translate('xpack.siem.case.caseView.connectors', { + defaultMessage: 'External incident management system', +}); + +export const EDIT_CONNECTOR = i18n.translate('xpack.siem.case.caseView.editConnector', { + defaultMessage: 'Change external incident management system', +}); diff --git a/x-pack/plugins/siem/public/pages/detection_engine/components/signals/actions.test.tsx b/x-pack/plugins/siem/public/pages/detection_engine/components/signals/actions.test.tsx index 8aaed08a0a0a1..ab75fcb6d6d1f 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/components/signals/actions.test.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/components/signals/actions.test.tsx @@ -15,6 +15,7 @@ import { } from '../../../../mock/'; import { CreateTimeline, UpdateTimelineLoading } from './types'; import { Ecs } from '../../../../graphql/types'; +import { TimelineType } from '../../../../../common/types/timeline'; jest.mock('apollo-client'); @@ -215,6 +216,9 @@ describe('signals actions', () => { sortDirection: 'desc', }, title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, version: null, width: 1100, }, diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index d383b5cd464ce..8e79f037d82b0 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -62,6 +62,7 @@ export const getActions = ( }, }, { + 'data-test-subj': 'exportRuleAction', description: i18n.EXPORT_RULE, icon: 'exportAction', name: i18n.EXPORT_RULE, diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.test.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.test.tsx index 4cfad36b2933f..13ae3ee3d3b7d 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.test.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.test.tsx @@ -19,6 +19,15 @@ describe('RuleActionsField', () => { triggers_actions_ui: { actionTypeRegistry: {}, }, + application: { + capabilities: { + actions: { + delete: true, + save: true, + show: true, + }, + }, + }, }, }); const Component = () => { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx index b743888b67815..d53cf52cc67b9 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx @@ -27,6 +27,8 @@ export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables http, triggers_actions_ui: { actionTypeRegistry }, notifications, + docLinks, + application: { capabilities }, } = useKibana().services; const setActionIdByIndex = useCallback( @@ -78,6 +80,8 @@ export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables actionTypes={supportedActionTypes} defaultActionMessage={DEFAULT_ACTION_MESSAGE} toastNotifications={notifications.toasts} + docLinks={docLinks} + capabilities={capabilities} /> ); }; diff --git a/x-pack/plugins/siem/public/store/timeline/defaults.ts b/x-pack/plugins/siem/public/store/timeline/defaults.ts index 7f04bb4c4dad0..9203720e2e28c 100644 --- a/x-pack/plugins/siem/public/store/timeline/defaults.ts +++ b/x-pack/plugins/siem/public/store/timeline/defaults.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TimelineType } from '../../../common/types/timeline'; + import { Direction } from '../../graphql/types'; import { DEFAULT_TIMELINE_WIDTH } from '../../components/timeline/body/constants'; import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers'; @@ -33,6 +35,9 @@ export const timelineDefaults: SubsetTimelineModel & Pick { describe('#convertTimelineAsInput ', () => { @@ -135,6 +138,9 @@ describe('Epic Timeline', () => { }, loadingEventIds: [], title: 'saved', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, noteIds: [], pinnedEventIds: {}, pinnedEventsSaveObject: {}, @@ -283,6 +289,9 @@ describe('Epic Timeline', () => { columnId: '@timestamp', sortDirection: 'desc', }, + templateTimelineId: null, + templateTimelineVersion: null, + timelineType: TimelineType.default, title: 'saved', }); }); diff --git a/x-pack/plugins/siem/public/store/timeline/epic.ts b/x-pack/plugins/siem/public/store/timeline/epic.ts index 6812d8d8aa672..a7b8c48b45068 100644 --- a/x-pack/plugins/siem/public/store/timeline/epic.ts +++ b/x-pack/plugins/siem/public/store/timeline/epic.ts @@ -29,6 +29,7 @@ import { } from 'rxjs/operators'; import { esFilters, Filter, MatchAllFilter } from '../../../../../../src/plugins/data/public'; +import { TimelineType } from '../../../common/types/timeline'; import { TimelineInput, ResponseTimeline, TimelineResult } from '../../graphql/types'; import { AppApolloClient } from '../../lib/lib'; import { addError } from '../app/actions'; @@ -236,6 +237,9 @@ export const createTimelineEpic = (): Epic< ...savedTimeline, savedObjectId: response.timeline.savedObjectId, version: response.timeline.version, + timelineType: response.timeline.timelineType ?? TimelineType.default, + templateTimelineId: response.timeline.templateTimelineId ?? null, + templateTimelineVersion: response.timeline.templateTimelineVersion ?? null, isSaving: false, }, }), @@ -283,6 +287,9 @@ const timelineInput: TimelineInput = { kqlMode: null, kqlQuery: null, title: null, + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, dateRange: null, savedQueryId: null, sort: null, diff --git a/x-pack/plugins/siem/public/store/timeline/helpers.ts b/x-pack/plugins/siem/public/store/timeline/helpers.ts index 19de49918d100..adab029c11150 100644 --- a/x-pack/plugins/siem/public/store/timeline/helpers.ts +++ b/x-pack/plugins/siem/public/store/timeline/helpers.ts @@ -7,6 +7,7 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import { Filter } from '../../../../../../src/plugins/data/public'; + import { getColumnWidthFromType } from '../../components/timeline/body/column_headers/helpers'; import { Sort } from '../../components/timeline/body/sort'; import { diff --git a/x-pack/plugins/siem/public/store/timeline/model.ts b/x-pack/plugins/siem/public/store/timeline/model.ts index 15bd2980e4aeb..7885064380eff 100644 --- a/x-pack/plugins/siem/public/store/timeline/model.ts +++ b/x-pack/plugins/siem/public/store/timeline/model.ts @@ -5,6 +5,9 @@ */ import { Filter } from '../../../../../../src/plugins/data/public'; + +import { TimelineTypeLiteralWithNull } from '../../../common/types/timeline'; + import { DataProvider } from '../../components/timeline/data_providers/data_provider'; import { Sort } from '../../components/timeline/body/sort'; import { PinnedEvent, TimelineNonEcsData } from '../../graphql/types'; @@ -77,6 +80,12 @@ export interface TimelineModel { }; /** Title */ title: string; + /** timelineTypes: default | template */ + timelineType: TimelineTypeLiteralWithNull; + /** an unique id for template timeline */ + templateTimelineId: string | null; + /** null for default timeline, number for template timeline */ + templateTimelineVersion: number | null; /** Notes added to the timeline itself. Notes added to events are stored (separately) in `eventIdToNote` */ noteIds: string[]; /** Events pinned to this timeline */ @@ -125,6 +134,9 @@ export type SubsetTimelineModel = Readonly< | 'kqlMode' | 'kqlQuery' | 'title' + | 'timelineType' + | 'templateTimelineId' + | 'templateTimelineVersion' | 'loadingEventIds' | 'noteIds' | 'pinnedEventIds' diff --git a/x-pack/plugins/siem/public/store/timeline/reducer.test.ts b/x-pack/plugins/siem/public/store/timeline/reducer.test.ts index 58fc1c7e1e3df..42c6d6ecb0e51 100644 --- a/x-pack/plugins/siem/public/store/timeline/reducer.test.ts +++ b/x-pack/plugins/siem/public/store/timeline/reducer.test.ts @@ -6,6 +6,8 @@ import { cloneDeep, set } from 'lodash/fp'; +import { TimelineType } from '../../../common/types/timeline'; + import { IS_OPERATOR, DataProvider, @@ -80,6 +82,9 @@ const timelineByIdMock: TimelineById = { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, noteIds: [], pinnedEventIds: {}, pinnedEventsSaveObject: {}, @@ -1110,6 +1115,9 @@ describe('Timeline', () => { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, noteIds: [], dateRange: { start: 0, @@ -1202,6 +1210,9 @@ describe('Timeline', () => { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, noteIds: [], dateRange: { start: 0, @@ -1400,6 +1411,9 @@ describe('Timeline', () => { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, noteIds: [], dateRange: { start: 0, @@ -1492,6 +1506,9 @@ describe('Timeline', () => { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, noteIds: [], dateRange: { start: 0, @@ -1679,6 +1696,9 @@ describe('Timeline', () => { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, noteIds: [], dateRange: { start: 0, @@ -1755,6 +1775,9 @@ describe('Timeline', () => { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, noteIds: [], dateRange: { start: 0, @@ -1855,6 +1878,9 @@ describe('Timeline', () => { kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, noteIds: [], dateRange: { start: 0, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/403_response_to_a_post.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/403_response_to_a_post.json index d4118d0686b11..73005db600ca0 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/403_response_to_a_post.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/403_response_to_a_post.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/405_response_method_not_allowed.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/405_response_method_not_allowed.json index da27f0a71d281..de080ff342448 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/405_response_method_not_allowed.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/405_response_method_not_allowed.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_adversary_behavior_detected.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_adversary_behavior_detected.json index c83c0e01d7fa0..ca97e9901975f 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_adversary_behavior_detected.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_adversary_behavior_detected.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_prevented.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_prevented.json index 03024ad15396e..11b9fa93f5f17 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_prevented.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_prevented.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_detected.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_detected.json index e5a128029f585..ae4b59d101a3a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_detected.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_detected.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_prevented.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_prevented.json index 1c05743fae62f..2db3fbbde7547 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_prevented.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_prevented.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_detected.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_detected.json index 3396a8563ba1c..a57d56cec9bcd 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_detected.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_detected.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_prevented.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_prevented.json index 2f70c539414c6..f8f1b774a191a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_prevented.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_prevented.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_detected.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_detected.json index cbf6c286a439f..4024a50c3a0fe 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_detected.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_detected.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_prevented.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_prevented.json index 49c7c160e5daf..b21bd00229c04 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_prevented.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_prevented.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_detected.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_detected.json index e836bd037ddc5..1aba34f7b15c0 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_detected.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_detected.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_prevented.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_prevented.json index e9ac8d7ba6686..b383349b5e204 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_prevented.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_prevented.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_detected.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_detected.json index 8e25832b0e89a..d7f5b24548344 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_detected.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_detected.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_prevented.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_prevented.json index a59428275ca22..a2595dee2f724 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_prevented.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_prevented.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_detected.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_detected.json index 22091d8c9b68f..9dd62717958e1 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_detected.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_detected.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_prevented.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_prevented.json index 947bfcbba39a0..cfa9ff6cca2ee 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_prevented.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_prevented.json @@ -17,4 +17,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_adding_the_hidden_file_attribute_with_via_attribexe.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_adding_the_hidden_file_attribute_with_via_attribexe.json index 25d2232d3f6dc..b61a6236db565 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_adding_the_hidden_file_attribute_with_via_attribexe.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_adding_the_hidden_file_attribute_with_via_attribexe.json @@ -47,4 +47,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_adobe_hijack_persistence.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_adobe_hijack_persistence.json index 3b4d2bc040217..8d455f501d2b2 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_adobe_hijack_persistence.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_adobe_hijack_persistence.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_clearing_windows_event_logs.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_clearing_windows_event_logs.json index 1c73d6c276ce6..d5e60ce3c10d9 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_clearing_windows_event_logs.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_clearing_windows_event_logs.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_delete_volume_usn_journal_with_fsutil.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_delete_volume_usn_journal_with_fsutil.json index 0bfa18398eada..6f65a871fce77 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_delete_volume_usn_journal_with_fsutil.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_delete_volume_usn_journal_with_fsutil.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_deleting_backup_catalogs_with_wbadmin.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_deleting_backup_catalogs_with_wbadmin.json index e7293eda6390f..97029cebd665a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_deleting_backup_catalogs_with_wbadmin.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_deleting_backup_catalogs_with_wbadmin.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_direct_outbound_smb_connection.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_direct_outbound_smb_connection.json index 2896d27e19112..8bbdc72573e0d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_direct_outbound_smb_connection.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_direct_outbound_smb_connection.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_disable_windows_firewall_rules_with_netsh.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_disable_windows_firewall_rules_with_netsh.json index 42fe51f4e0373..03af66f2cffb2 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_disable_windows_firewall_rules_with_netsh.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_disable_windows_firewall_rules_with_netsh.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_encoding_or_decoding_files_via_certutil.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_encoding_or_decoding_files_via_certutil.json index eef112503da5b..aaca5242e717b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_encoding_or_decoding_files_via_certutil.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_encoding_or_decoding_files_via_certutil.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_local_scheduled_task_commands.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_local_scheduled_task_commands.json index dbacb2537e60f..7b674c270f884 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_local_scheduled_task_commands.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_local_scheduled_task_commands.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_local_service_commands.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_local_service_commands.json index 648e83b4a5267..e842b732254ca 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_local_service_commands.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_local_service_commands.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_msbuild_making_network_connections.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_msbuild_making_network_connections.json index 5e8b260d44b55..f3d75c7fead8b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_msbuild_making_network_connections.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_msbuild_making_network_connections.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_mshta_making_network_connections.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_mshta_making_network_connections.json index 88bd248e258d8..eb2dd0eeff6ea 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_mshta_making_network_connections.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_mshta_making_network_connections.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_psexec_lateral_movement_command.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_psexec_lateral_movement_command.json index f763d2aa03363..2abf38eb1b0ef 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_psexec_lateral_movement_command.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_psexec_lateral_movement_command.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_office_child_process.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_office_child_process.json index 95c9c6b72f8f4..e234688a432e2 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_office_child_process.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_office_child_process.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_outlook_child_process.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_outlook_child_process.json index 7f6c9257fabfd..dcc5e5a095f12 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_outlook_child_process.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_outlook_child_process.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_system_shells_via_services.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_system_shells_via_services.json index f1b1879fc2652..504c41f05871a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_system_shells_via_services.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_system_shells_via_services.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_network_connection_via_rundll32.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_network_connection_via_rundll32.json index 2a7960c939d01..c2be97f110a38 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_network_connection_via_rundll32.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_network_connection_via_rundll32.json @@ -5,7 +5,7 @@ ], "language": "kuery", "name": "Unusual Network Connection via RunDLL32", - "query": "process.name:rundll32.exe and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "process.name:rundll32.exe and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or 127.0.0.0/8)", "risk_score": 21, "rule_id": "52aaab7b-b51c-441a-89ce-4387b3aea886", "severity": "low", @@ -31,5 +31,5 @@ } ], "type": "query", - "version": 2 -} \ No newline at end of file + "version": 3 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_parentchild_relationship.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_parentchild_relationship.json index 9a28c87c77089..ea87ce1aea81d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_parentchild_relationship.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_parentchild_relationship.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_process_network_connection.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_process_network_connection.json index 43a3d6f6af0b2..481768e76ee37 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_process_network_connection.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_process_network_connection.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_user_account_creation.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_user_account_creation.json index 7054e7f67c358..247a1cde22596 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_user_account_creation.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_user_account_creation.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_vssadmin.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_vssadmin.json index 24f1cb72504f3..700fd5215133d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_vssadmin.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_vssadmin.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_wmic.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_wmic.json index bad3c65024e42..59222be6c598a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_wmic.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_wmic.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_windows_script_executing_powershell.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_windows_script_executing_powershell.json index 52323b169cb22..27411e35ee828 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_windows_script_executing_powershell.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/eql_windows_script_executing_powershell.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts index 9e185b5a5ef7c..0a2317898e8a3 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -53,88 +53,105 @@ import rule43 from './linux_anomalous_network_service.json'; import rule44 from './linux_anomalous_network_url_activity.json'; import rule45 from './linux_anomalous_process_all_hosts.json'; import rule46 from './linux_anomalous_user_name.json'; -import rule47 from './linux_hping_activity.json'; -import rule48 from './linux_iodine_activity.json'; -import rule49 from './linux_kernel_module_activity.json'; -import rule50 from './linux_mknod_activity.json'; -import rule51 from './linux_netcat_network_connection.json'; -import rule52 from './linux_nmap_activity.json'; -import rule53 from './linux_nping_activity.json'; -import rule54 from './linux_process_started_in_temp_directory.json'; -import rule55 from './linux_shell_activity_by_web_server.json'; -import rule56 from './linux_socat_activity.json'; -import rule57 from './linux_strace_activity.json'; -import rule58 from './linux_tcpdump_activity.json'; -import rule59 from './linux_whoami_commmand.json'; -import rule60 from './network_dns_directly_to_the_internet.json'; -import rule61 from './network_ftp_file_transfer_protocol_activity_to_the_internet.json'; -import rule62 from './network_irc_internet_relay_chat_protocol_activity_to_the_internet.json'; -import rule63 from './network_nat_traversal_port_activity.json'; -import rule64 from './network_port_26_activity.json'; -import rule65 from './network_port_8000_activity_to_the_internet.json'; -import rule66 from './network_pptp_point_to_point_tunneling_protocol_activity.json'; -import rule67 from './network_proxy_port_activity_to_the_internet.json'; -import rule68 from './network_rdp_remote_desktop_protocol_from_the_internet.json'; -import rule69 from './network_rdp_remote_desktop_protocol_to_the_internet.json'; -import rule70 from './network_rpc_remote_procedure_call_from_the_internet.json'; -import rule71 from './network_rpc_remote_procedure_call_to_the_internet.json'; -import rule72 from './network_smb_windows_file_sharing_activity_to_the_internet.json'; -import rule73 from './network_smtp_to_the_internet.json'; -import rule74 from './network_sql_server_port_activity_to_the_internet.json'; -import rule75 from './network_ssh_secure_shell_from_the_internet.json'; -import rule76 from './network_ssh_secure_shell_to_the_internet.json'; -import rule77 from './network_telnet_port_activity.json'; -import rule78 from './network_tor_activity_to_the_internet.json'; -import rule79 from './network_vnc_virtual_network_computing_from_the_internet.json'; -import rule80 from './network_vnc_virtual_network_computing_to_the_internet.json'; -import rule81 from './null_user_agent.json'; -import rule82 from './packetbeat_dns_tunneling.json'; -import rule83 from './packetbeat_rare_dns_question.json'; -import rule84 from './packetbeat_rare_server_domain.json'; -import rule85 from './packetbeat_rare_urls.json'; -import rule86 from './packetbeat_rare_user_agent.json'; -import rule87 from './rare_process_by_host_linux.json'; -import rule88 from './rare_process_by_host_windows.json'; -import rule89 from './sqlmap_user_agent.json'; -import rule90 from './suspicious_login_activity.json'; -import rule91 from './windows_anomalous_network_activity.json'; -import rule92 from './windows_anomalous_path_activity.json'; -import rule93 from './windows_anomalous_process_all_hosts.json'; -import rule94 from './windows_anomalous_process_creation.json'; -import rule95 from './windows_anomalous_script.json'; -import rule96 from './windows_anomalous_service.json'; -import rule97 from './windows_anomalous_user_name.json'; -import rule98 from './windows_certutil_network_connection.json'; -import rule99 from './windows_command_prompt_connecting_to_the_internet.json'; -import rule100 from './windows_command_shell_started_by_powershell.json'; -import rule101 from './windows_command_shell_started_by_svchost.json'; -import rule102 from './windows_credential_dumping_msbuild.json'; -import rule103 from './windows_cve_2020_0601.json'; -import rule104 from './windows_defense_evasion_via_filter_manager.json'; -import rule105 from './windows_execution_msbuild_started_by_office_app.json'; -import rule106 from './windows_execution_msbuild_started_by_script.json'; -import rule107 from './windows_execution_msbuild_started_by_system_process.json'; -import rule108 from './windows_execution_msbuild_started_renamed.json'; -import rule109 from './windows_execution_msbuild_started_unusal_process.json'; -import rule110 from './windows_execution_via_compiled_html_file.json'; -import rule111 from './windows_execution_via_net_com_assemblies.json'; -import rule112 from './windows_execution_via_trusted_developer_utilities.json'; -import rule113 from './windows_html_help_executable_program_connecting_to_the_internet.json'; -import rule114 from './windows_injection_msbuild.json'; -import rule115 from './windows_misc_lolbin_connecting_to_the_internet.json'; -import rule116 from './windows_modification_of_boot_config.json'; -import rule117 from './windows_msxsl_network.json'; -import rule118 from './windows_net_command_system_account.json'; -import rule119 from './windows_persistence_via_application_shimming.json'; -import rule120 from './windows_priv_escalation_via_accessibility_features.json'; -import rule121 from './windows_process_discovery_via_tasklist_command.json'; -import rule122 from './windows_rare_user_runas_event.json'; -import rule123 from './windows_rare_user_type10_remote_login.json'; -import rule124 from './windows_register_server_program_connecting_to_the_internet.json'; -import rule125 from './windows_suspicious_pdf_reader.json'; -import rule126 from './windows_uac_bypass_event_viewer.json'; -import rule127 from './windows_whoami_command_activity.json'; - +import rule47 from './linux_attempt_to_disable_iptables_or_firewall.json'; +import rule48 from './linux_attempt_to_disable_syslog_service.json'; +import rule49 from './linux_base16_or_base32_encoding_or_decoding_activity.json'; +import rule50 from './linux_base64_encoding_or_decoding_activity.json'; +import rule51 from './linux_disable_selinux_attempt.json'; +import rule52 from './linux_file_deletion_via_shred.json'; +import rule53 from './linux_file_mod_writable_dir.json'; +import rule54 from './linux_hex_encoding_or_decoding_activity.json'; +import rule55 from './linux_hping_activity.json'; +import rule56 from './linux_iodine_activity.json'; +import rule57 from './linux_kernel_module_activity.json'; +import rule58 from './linux_kernel_module_enumeration.json'; +import rule59 from './linux_kernel_module_removal.json'; +import rule60 from './linux_mknod_activity.json'; +import rule61 from './linux_netcat_network_connection.json'; +import rule62 from './linux_nmap_activity.json'; +import rule63 from './linux_nping_activity.json'; +import rule64 from './linux_perl_tty_shell.json'; +import rule65 from './linux_process_started_in_temp_directory.json'; +import rule66 from './linux_python_tty_shell.json'; +import rule67 from './linux_setgid_bit_set_via_chmod.json'; +import rule68 from './linux_setuid_bit_set_via_chmod.json'; +import rule69 from './linux_shell_activity_by_web_server.json'; +import rule70 from './linux_socat_activity.json'; +import rule71 from './linux_strace_activity.json'; +import rule72 from './linux_sudoers_file_mod.json'; +import rule73 from './linux_tcpdump_activity.json'; +import rule74 from './linux_telnet_network_activity_external.json'; +import rule75 from './linux_telnet_network_activity_internal.json'; +import rule76 from './linux_virtual_machine_fingerprinting.json'; +import rule77 from './linux_whoami_commmand.json'; +import rule78 from './network_dns_directly_to_the_internet.json'; +import rule79 from './network_ftp_file_transfer_protocol_activity_to_the_internet.json'; +import rule80 from './network_irc_internet_relay_chat_protocol_activity_to_the_internet.json'; +import rule81 from './network_nat_traversal_port_activity.json'; +import rule82 from './network_port_26_activity.json'; +import rule83 from './network_port_8000_activity_to_the_internet.json'; +import rule84 from './network_pptp_point_to_point_tunneling_protocol_activity.json'; +import rule85 from './network_proxy_port_activity_to_the_internet.json'; +import rule86 from './network_rdp_remote_desktop_protocol_from_the_internet.json'; +import rule87 from './network_rdp_remote_desktop_protocol_to_the_internet.json'; +import rule88 from './network_rpc_remote_procedure_call_from_the_internet.json'; +import rule89 from './network_rpc_remote_procedure_call_to_the_internet.json'; +import rule90 from './network_smb_windows_file_sharing_activity_to_the_internet.json'; +import rule91 from './network_smtp_to_the_internet.json'; +import rule92 from './network_sql_server_port_activity_to_the_internet.json'; +import rule93 from './network_ssh_secure_shell_from_the_internet.json'; +import rule94 from './network_ssh_secure_shell_to_the_internet.json'; +import rule95 from './network_telnet_port_activity.json'; +import rule96 from './network_tor_activity_to_the_internet.json'; +import rule97 from './network_vnc_virtual_network_computing_from_the_internet.json'; +import rule98 from './network_vnc_virtual_network_computing_to_the_internet.json'; +import rule99 from './null_user_agent.json'; +import rule100 from './packetbeat_dns_tunneling.json'; +import rule101 from './packetbeat_rare_dns_question.json'; +import rule102 from './packetbeat_rare_server_domain.json'; +import rule103 from './packetbeat_rare_urls.json'; +import rule104 from './packetbeat_rare_user_agent.json'; +import rule105 from './rare_process_by_host_linux.json'; +import rule106 from './rare_process_by_host_windows.json'; +import rule107 from './sqlmap_user_agent.json'; +import rule108 from './suspicious_login_activity.json'; +import rule109 from './windows_anomalous_network_activity.json'; +import rule110 from './windows_anomalous_path_activity.json'; +import rule111 from './windows_anomalous_process_all_hosts.json'; +import rule112 from './windows_anomalous_process_creation.json'; +import rule113 from './windows_anomalous_script.json'; +import rule114 from './windows_anomalous_service.json'; +import rule115 from './windows_anomalous_user_name.json'; +import rule116 from './windows_certutil_network_connection.json'; +import rule117 from './windows_command_prompt_connecting_to_the_internet.json'; +import rule118 from './windows_command_shell_started_by_powershell.json'; +import rule119 from './windows_command_shell_started_by_svchost.json'; +import rule120 from './windows_credential_dumping_msbuild.json'; +import rule121 from './windows_cve_2020_0601.json'; +import rule122 from './windows_defense_evasion_via_filter_manager.json'; +import rule123 from './windows_execution_msbuild_started_by_office_app.json'; +import rule124 from './windows_execution_msbuild_started_by_script.json'; +import rule125 from './windows_execution_msbuild_started_by_system_process.json'; +import rule126 from './windows_execution_msbuild_started_renamed.json'; +import rule127 from './windows_execution_msbuild_started_unusal_process.json'; +import rule128 from './windows_execution_via_compiled_html_file.json'; +import rule129 from './windows_execution_via_net_com_assemblies.json'; +import rule130 from './windows_execution_via_trusted_developer_utilities.json'; +import rule131 from './windows_html_help_executable_program_connecting_to_the_internet.json'; +import rule132 from './windows_injection_msbuild.json'; +import rule133 from './windows_misc_lolbin_connecting_to_the_internet.json'; +import rule134 from './windows_modification_of_boot_config.json'; +import rule135 from './windows_msxsl_network.json'; +import rule136 from './windows_net_command_system_account.json'; +import rule137 from './windows_persistence_via_application_shimming.json'; +import rule138 from './windows_priv_escalation_via_accessibility_features.json'; +import rule139 from './windows_process_discovery_via_tasklist_command.json'; +import rule140 from './windows_rare_user_runas_event.json'; +import rule141 from './windows_rare_user_type10_remote_login.json'; +import rule142 from './windows_register_server_program_connecting_to_the_internet.json'; +import rule143 from './windows_suspicious_pdf_reader.json'; +import rule144 from './windows_uac_bypass_event_viewer.json'; +import rule145 from './windows_whoami_command_activity.json'; export const rawRules = [ rule1, rule2, @@ -263,4 +280,22 @@ export const rawRules = [ rule125, rule126, rule127, + rule128, + rule129, + rule130, + rule131, + rule132, + rule133, + rule134, + rule135, + rule136, + rule137, + rule138, + rule139, + rule140, + rule141, + rule142, + rule143, + rule144, + rule145, ]; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json index 41f38173dba33..d910f83b0c8bd 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "linux_anomalous_network_activity_ecs", "name": "Unusual Linux Network Activity", + "note": "### Investigating Unusual Network Activity ###\nSignals from this rule indicate the presence of network activity from a Linux process for which network activity is rare and unusual. Here are some possible avenues of investigation:\n- Consider the IP addresses and ports. Are these used by normal but infrequent network workflows? Are they expected or unexpected? \n- If the destination IP address is remote or external, does it associate with an expected domain, organization or geography? Note: avoid interacting directly with suspected malicious IP addresses.\n- Consider the user as identified by the username field. Is this network activity part of an expected workflow for the user who ran the program?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business or maintenance process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "ML" ], "type": "machine_learning", - "note": "### Investigating Unusual Network Activity ###\nSignals from this rule indicate the presence of network activity from a Linux process for which network activity is rare and unusual. Here are some possible avenues of investigation:\n- Consider the IP addresses and ports. Are these used by normal but infrequent network workflows? Are they expected or unexpected? \n- If the destination IP address is remote or external, does it associate with an expected domain, organization or geography? Note: avoid interacting directly with suspected malicious IP addresses.\n- Consider the user as identified by the username field. Is this network activity part of an expected workflow for the user who ran the program?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business or maintenance process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json index d435d4c10f05c..aa0d1cb125aed 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json index 0b82ce99d0b7f..5d137b81d1314 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json index 26af34e18a4c8..3732e575a2e41 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json index 103171bcdfe50..259f0147953ad 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "linux_anomalous_process_all_hosts_ecs", "name": "Anomalous Process For a Linux Population", + "note": "### Investigating an Unusual Linux Process ###\nSignals from this rule indicate the presence of a Linux process that is rare and unusual for all of the monitored Linux hosts for which Auditbeat data is available. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "ML" ], "type": "machine_learning", - "note": "### Investigating an Unusual Linux Process ###\nSignals from this rule indicate the presence of a Linux process that is rare and unusual for all of the monitored Linux hosts for which Auditbeat data is available. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json index 6642bb5d73fbd..2e7bd0d1d99d7 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "linux_anomalous_user_name_ecs", "name": "Unusual Linux Username", + "note": "### Investigating an Unusual Linux User ###\nSignals from this rule indicate activity for a Linux user name that is rare and unusual. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host? Could this be related to troubleshooting or debugging activity by a developer or site reliability engineer?\n- Examine the history of user activity. If this user manifested only very recently, it might be a service account for a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks that the user is performing.", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "ML" ], "type": "machine_learning", - "note": "### Investigating an Unusual Linux User ###\nSignals from this rule indicate activity for a Linux user name that is rare and unusual. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host? Could this be related to troubleshooting or debugging activity by a developer or site reliability engineer?\n- Examine the history of user activity. If this user manifested only very recently, it might be a service account for a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks that the user is performing.", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_iptables_or_firewall.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_iptables_or_firewall.json new file mode 100644 index 0000000000000..77d0ddc22ff40 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_iptables_or_firewall.json @@ -0,0 +1,35 @@ +{ + "description": "Adversaries may attempt to disable the iptables or firewall service in an attempt to affect how a host is allowed to receive or send network traffic.", + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Attempt to Disable IPTables or Firewall", + "query": "event.action:(executed or process_started) and (process.name:service and process.args:stop or process.name:chkconfig and process.args:off) and process.args:(ip6tables or iptables) or process.name:systemctl and process.args:(firewalld and (disable or stop or kill))", + "risk_score": 47, + "rule_id": "125417b8-d3df-479f-8418-12d7e034fee3", + "severity": "medium", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_syslog_service.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_syslog_service.json new file mode 100644 index 0000000000000..d4584035d53b4 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_syslog_service.json @@ -0,0 +1,35 @@ +{ + "description": "Adversaries may attempt to disable the syslog service in an attempt to an attempt to disrupt event logging and evade detection by security controls.", + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Attempt to Disable Syslog Service", + "query": "event.action:(executed or process_started) and ((process.name:service and process.args:stop) or (process.name:chkconfig and process.args:off) or (process.name:systemctl and process.args:(disable or stop or kill))) and process.args:(syslog or rsyslog or \"syslog-ng\")", + "risk_score": 47, + "rule_id": "2f8a1226-5720-437d-9c20-e0029deb6194", + "severity": "medium", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_base16_or_base32_encoding_or_decoding_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_base16_or_base32_encoding_or_decoding_activity.json new file mode 100644 index 0000000000000..9518138ad6799 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_base16_or_base32_encoding_or_decoding_activity.json @@ -0,0 +1,53 @@ +{ + "description": "Adversaries may encode/decode data in an attempt to evade detection by host- or network-based security controls.", + "false_positives": [ + "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Base16 or Base32 Encoding/Decoding Activity", + "query": "event.action:(executed or process_started) and process.name:(base16 or base32 or base32plain or base32hex)", + "risk_score": 21, + "rule_id": "debff20a-46bc-4a4d-bae5-5cdd14222795", + "severity": "low", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1140", + "name": "Deobfuscate/Decode Files or Information", + "reference": "https://attack.mitre.org/techniques/T1140/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1027", + "name": "Obfuscated Files or Information", + "reference": "https://attack.mitre.org/techniques/T1027/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_base64_encoding_or_decoding_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_base64_encoding_or_decoding_activity.json new file mode 100644 index 0000000000000..37f3e3eaccd90 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_base64_encoding_or_decoding_activity.json @@ -0,0 +1,53 @@ +{ + "description": "Adversaries may encode/decode data in an attempt to evade detection by host- or network-based security controls.", + "false_positives": [ + "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Base64 Encoding/Decoding Activity", + "query": "event.action:(executed or process_started) and process.name:(base64 or base64plain or base64url or base64mime or base64pem)", + "risk_score": 21, + "rule_id": "97f22dab-84e8-409d-955e-dacd1d31670b", + "severity": "low", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1140", + "name": "Deobfuscate/Decode Files or Information", + "reference": "https://attack.mitre.org/techniques/T1140/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1027", + "name": "Obfuscated Files or Information", + "reference": "https://attack.mitre.org/techniques/T1027/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_disable_selinux_attempt.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_disable_selinux_attempt.json new file mode 100644 index 0000000000000..d33331cd4f8d4 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_disable_selinux_attempt.json @@ -0,0 +1,35 @@ +{ + "description": "Identifies potential attempts to disable Security-Enhanced Linux (SELinux), which is a Linux kernel security feature to support access control policies. Adversaries may disable security tools to avoid possible detection of their tools and activities.", + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Potential Disabling of SELinux", + "query": "event.action:executed and process.name:setenforce and process.args:0", + "risk_score": 47, + "rule_id": "eb9eb8ba-a983-41d9-9c93-a1c05112ca5e", + "severity": "medium", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_file_deletion_via_shred.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_file_deletion_via_shred.json new file mode 100644 index 0000000000000..4fd72a212f0ba --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_file_deletion_via_shred.json @@ -0,0 +1,35 @@ +{ + "description": "Malware or other files dropped or created on a system by an adversary may leave traces behind as to what was done within a network and how. Adversaries may remove these files over the course of an intrusion to keep their footprint low or remove them at the end as part of the post-intrusion cleanup process.", + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "File Deletion via Shred", + "query": "event.action:(executed or process_started) and process.name:shred and process.args:(\"-u\" or \"--remove\" or \"-z\" or \"--zero\")", + "risk_score": 21, + "rule_id": "a1329140-8de3-4445-9f87-908fb6d824f4", + "severity": "low", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1107", + "name": "File Deletion", + "reference": "https://attack.mitre.org/techniques/T1107/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_file_mod_writable_dir.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_file_mod_writable_dir.json new file mode 100644 index 0000000000000..66c5848b17707 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_file_mod_writable_dir.json @@ -0,0 +1,38 @@ +{ + "description": "Identifies file permission modifications in common writable directories by a non-root user. Adversaries often drop files or payloads into a writable directory and change permissions prior to execution.", + "false_positives": [ + "Certain programs or applications may modify files or change ownership in writable directories. These can be exempted by username." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "File Permission Modification in Writable Directory", + "query": "event.action:executed and process.name:(chmod or chown or chattr or chgrp) and process.working_directory:(/tmp or /var/tmp or /dev/shm) and not user.name:root", + "risk_score": 21, + "rule_id": "9f9a2a82-93a8-4b1a-8778-1780895626d4", + "severity": "low", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1222", + "name": "File and Directory Permissions Modification", + "reference": "https://attack.mitre.org/techniques/T1222/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_hex_encoding_or_decoding_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_hex_encoding_or_decoding_activity.json new file mode 100644 index 0000000000000..a67d310d2ad81 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_hex_encoding_or_decoding_activity.json @@ -0,0 +1,53 @@ +{ + "description": "Adversaries may encode/decode data in an attempt to evade detection by host- or network-based security controls.", + "false_positives": [ + "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Hex Encoding/Decoding Activity", + "query": "event.action:(executed or process_started) and process.name:(hex or xxd)", + "risk_score": 21, + "rule_id": "a9198571-b135-4a76-b055-e3e5a476fd83", + "severity": "low", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1140", + "name": "Deobfuscate/Decode Files or Information", + "reference": "https://attack.mitre.org/techniques/T1140/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1027", + "name": "Obfuscated Files or Information", + "reference": "https://attack.mitre.org/techniques/T1027/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json index 04a56241ea6f6..bd954683723f4 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json index 80358cc775e3b..63b0155bbd82c 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_activity.json index b50fcc4c9980b..95fe337fbfd1b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_activity.json @@ -38,4 +38,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_enumeration.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_enumeration.json new file mode 100644 index 0000000000000..85564506bcff9 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_enumeration.json @@ -0,0 +1,38 @@ +{ + "description": "Loadable Kernel Modules (or LKMs) are pieces of code that can be loaded and unloaded into the kernel upon demand. They extend the functionality of the kernel without the need to reboot the system. This identifies attempts to enumerate information about a kernel module.", + "false_positives": [ + "Security tools and device drivers may run these programs in order to enumerate kernel modules. Use of these programs by ordinary users is uncommon. These can be exempted by process name or username." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Enumeration of Kernel Modules", + "query": "event.action:executed and process.args:(kmod and list and sudo or sudo and (depmod or lsmod or modinfo))", + "risk_score": 47, + "rule_id": "2d8043ed-5bda-4caf-801c-c1feb7410504", + "severity": "medium", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1082", + "name": "System Information Discovery", + "reference": "https://attack.mitre.org/techniques/T1082/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_removal.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_removal.json new file mode 100644 index 0000000000000..bb88a2acad53d --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_removal.json @@ -0,0 +1,56 @@ +{ + "description": "Kernel modules are pieces of code that can be loaded and unloaded into the kernel upon demand. They extend the functionality of the kernel without the need to reboot the system. This rule identifies attempts to remove a kernel module.", + "false_positives": [ + "There is usually no reason to remove modules, but some buggy modules require it. These can be exempted by username. Note that some Linux distributions are not built to support the removal of modules at all." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Kernel Module Removal", + "query": "event.action:executed and process.args:(rmmod and sudo or modprobe and sudo and (\"--remove\" or \"-r\"))", + "references": [ + "http://man7.org/linux/man-pages/man8/modprobe.8.html" + ], + "risk_score": 73, + "rule_id": "cd66a5af-e34b-4bb0-8931-57d0a043f2ef", + "severity": "high", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1215", + "name": "Kernel Modules and Extensions", + "reference": "https://attack.mitre.org/techniques/T1215/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json index d65440e95ff17..21208ade670ee 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json index df8e46be7a1c3..caacef3b33deb 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json @@ -23,4 +23,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json index 2e5c899ebc625..99324460cc00a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json index 168b30121c4bb..b4d44c65cd89c 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_perl_tty_shell.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_perl_tty_shell.json new file mode 100644 index 0000000000000..2f003f8ec9d03 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_perl_tty_shell.json @@ -0,0 +1,35 @@ +{ + "description": "Identifies when a terminal (tty) is spawned via Perl. Attackers may upgrade a simple reverse shell to a fully interactive tty after obtaining initial access to a host.", + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Interactive Terminal Spawned via Perl", + "query": "event.action:executed and process.name:perl and process.args:(\"exec \\\"/bin/sh\\\";\" or \"exec \\\"/bin/dash\\\";\" or \"exec \\\"/bin/bash\\\";\")", + "risk_score": 73, + "rule_id": "05e5a668-7b51-4a67-93ab-e9af405c9ef3", + "severity": "high", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command-Line Interface", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json index 0865ac6c70cb2..c20a41ac91d02 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json @@ -18,4 +18,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_python_tty_shell.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_python_tty_shell.json new file mode 100644 index 0000000000000..42e014e919cad --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_python_tty_shell.json @@ -0,0 +1,35 @@ +{ + "description": "Identifies when a terminal (tty) is spawned via Python. Attackers may upgrade a simple reverse shell to a fully interactive tty after obtaining initial access to a host.", + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Interactive Terminal Spawned via Python", + "query": "event.action:executed and process.name:python and process.args:(\"import pty; pty.spawn(\\\"/bin/sh\\\")\" or \"import pty; pty.spawn(\\\"/bin/dash\\\")\" or \"import pty; pty.spawn(\\\"/bin/bash\\\")\")", + "risk_score": 73, + "rule_id": "d76b02ef-fc95-4001-9297-01cb7412232f", + "severity": "high", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command-Line Interface", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_setgid_bit_set_via_chmod.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_setgid_bit_set_via_chmod.json new file mode 100644 index 0000000000000..c104330348596 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_setgid_bit_set_via_chmod.json @@ -0,0 +1,51 @@ +{ + "description": "An adversary may add the setgid bit to a file or directory in order to run a file with the privileges of the owning group. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application with the setgid bit to get code running in a different user\u2019s context. Additionally, adversaries can use this mechanism on their own malware to make sure they're able to execute in elevated contexts in the future.", + "index": [ + "auditbeat-*" + ], + "language": "lucene", + "max_signals": 33, + "name": "Setgid Bit Set via chmod", + "query": "event.action:(executed OR process_started) AND process.name:chmod AND process.args:(g+s OR /2[0-9]{3}/) AND NOT user.name:root", + "risk_score": 21, + "rule_id": "3a86e085-094c-412d-97ff-2439731e59cb", + "severity": "low", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1166", + "name": "Setuid and Setgid", + "reference": "https://attack.mitre.org/techniques/T1166/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1166", + "name": "Setuid and Setgid", + "reference": "https://attack.mitre.org/techniques/T1166/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_setuid_bit_set_via_chmod.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_setuid_bit_set_via_chmod.json new file mode 100644 index 0000000000000..72b62b67aa2d4 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_setuid_bit_set_via_chmod.json @@ -0,0 +1,51 @@ +{ + "description": "An adversary may add the setuid bit to a file or directory in order to run a file with the privileges of the owning user. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application with the setuid bit to get code running in a different user\u2019s context. Additionally, adversaries can use this mechanism on their own malware to make sure they're able to execute in elevated contexts in the future.", + "index": [ + "auditbeat-*" + ], + "language": "lucene", + "max_signals": 33, + "name": "Setuid Bit Set via chmod", + "query": "event.action:(executed OR process_started) AND process.name:chmod AND process.args:(u+s OR /4[0-9]{3}/) AND NOT user.name:root", + "risk_score": 21, + "rule_id": "8a1b0278-0f9a-487d-96bd-d4833298e87a", + "severity": "low", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1166", + "name": "Setuid and Setgid", + "reference": "https://attack.mitre.org/techniques/T1166/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1166", + "name": "Setuid and Setgid", + "reference": "https://attack.mitre.org/techniques/T1166/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_shell_activity_by_web_server.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_shell_activity_by_web_server.json index e9c4c95bb9284..4d6000bda3b01 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_shell_activity_by_web_server.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_shell_activity_by_web_server.json @@ -8,7 +8,7 @@ ], "language": "kuery", "name": "Potential Shell via Web Server", - "query": "process.name:bash and user.name:(apache or www or www-data) and event.action:executed", + "query": "process.name:(bash or dash) and user.name:(apache or nginx or www or \"www-data\") and event.action:executed", "references": [ "https://pentestlab.blog/tag/web-shell/" ], @@ -37,5 +37,5 @@ } ], "type": "query", - "version": 2 -} \ No newline at end of file + "version": 3 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json index 404fea63aff94..b0f9a19bfacaa 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json index fbdfa9e66682d..9e449ebfdfd81 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_sudoers_file_mod.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_sudoers_file_mod.json new file mode 100644 index 0000000000000..3cb9259e92132 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_sudoers_file_mod.json @@ -0,0 +1,35 @@ +{ + "description": "A sudoers file specifies the commands that users or groups can run and from which terminals. Adversaries can take advantage of these configurations to execute commands as other users or spawn processes with higher privileges.", + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Sudoers File Modification", + "query": "event.module:file_integrity and event.action:updated and file.path:/etc/sudoers", + "risk_score": 21, + "rule_id": "931e25a5-0f5e-4ae0-ba0d-9e94eff7e3a4", + "severity": "low", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1169", + "name": "Sudo", + "reference": "https://attack.mitre.org/techniques/T1169/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_tcpdump_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_tcpdump_activity.json index 82771074e7c29..b372645cc492a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_tcpdump_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_tcpdump_activity.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_external.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_external.json new file mode 100644 index 0000000000000..9f6b80b8bf1ef --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_external.json @@ -0,0 +1,38 @@ +{ + "description": "Telnet provides a command line interface for communication with a remote device or server. This rule identifies Telnet network connections to publicly routable IP addresses.", + "false_positives": [ + "Telnet can be used for both benign or malicious purposes. Telnet is included by default in some Linux distributions, so its presence is not inherently suspicious. The use of Telnet to manage devices remotely has declined in recent years in favor of more secure protocols such as SSH. Telnet usage by non-automated tools or frameworks may be suspicious." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Connection to External Network via Telnet", + "query": "event.action:(\"connected-to\" or \"network_flow\") and process.name:telnet and not destination.ip:(127.0.0.0/8 or 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"FE80::/10\" or \"::1/128\")", + "risk_score": 47, + "rule_id": "e19e64ee-130e-4c07-961f-8a339f0b8362", + "severity": "medium", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_internal.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_internal.json new file mode 100644 index 0000000000000..a2e94f1d2d015 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_internal.json @@ -0,0 +1,38 @@ +{ + "description": "Telnet provides a command line interface for communication with a remote device or server. This rule identifies Telnet network connections to non-publicly routable IP addresses.", + "false_positives": [ + "Telnet can be used for both benign or malicious purposes. Telnet is included by default in some Linux distributions, so its presence is not inherently suspicious. The use of Telnet to manage devices remotely has declined in recent years in favor of more secure protocols such as SSH. Telnet usage by non-automated tools or frameworks may be suspicious." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Connection to Internal Network via Telnet", + "query": "event.action:(\"connected-to\" or \"network_flow\") and process.name:telnet and destination.ip:((10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"FE80::/10\") and not (127.0.0.0/8 or \"::1/128\"))", + "risk_score": 47, + "rule_id": "1b21abcc-4d9f-4b08-a7f5-316f5f94b973", + "severity": "medium", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_virtual_machine_fingerprinting.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_virtual_machine_fingerprinting.json new file mode 100644 index 0000000000000..28c4b6d6ee0e5 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_virtual_machine_fingerprinting.json @@ -0,0 +1,38 @@ +{ + "description": "An adversary may attempt to get detailed information about the operating system and hardware. This rule identifies common locations used to discover virtual machine hardware by a non-root user. This technique has been used by the Pupy RAT and other malware.", + "false_positives": [ + "Certain tools or automated software may enumerate hardware information. These tools can be exempted via user name or process arguments to eliminate potential noise." + ], + "index": [ + "auditbeat-*" + ], + "language": "kuery", + "name": "Virtual Machine Fingerprinting", + "query": "event.action:executed and process.args:(\"/sys/class/dmi/id/bios_version\" or \"/sys/class/dmi/id/product_name\" or \"/sys/class/dmi/id/chassis_vendor\" or \"/proc/scsi/scsi\" or \"/proc/ide/hd0/model\") and not user.name:root", + "risk_score": 73, + "rule_id": "5b03c9fb-9945-4d2f-9568-fd690fee3fba", + "severity": "high", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1082", + "name": "System Information Discovery", + "reference": "https://attack.mitre.org/techniques/T1082/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_whoami_commmand.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_whoami_commmand.json index 7e7f041581eb0..e96c8dc3887e0 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_whoami_commmand.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_whoami_commmand.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_dns_directly_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_dns_directly_to_the_internet.json index e08d681d14463..1ffabbc876e2e 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_dns_directly_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_dns_directly_to_the_internet.json @@ -39,4 +39,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ftp_file_transfer_protocol_activity_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ftp_file_transfer_protocol_activity_to_the_internet.json index 24c3bad817227..0649d408a5c22 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ftp_file_transfer_protocol_activity_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ftp_file_transfer_protocol_activity_to_the_internet.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json index bf286d4cab506..bdabfa4d5f38f 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_nat_traversal_port_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_nat_traversal_port_activity.json index 61c1e3d47cf7a..63bdd2b83e3bc 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_nat_traversal_port_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_nat_traversal_port_activity.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_port_26_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_port_26_activity.json index a9a39b61884c5..df809d2225352 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_port_26_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_port_26_activity.json @@ -54,4 +54,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_port_8000_activity_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_port_8000_activity_to_the_internet.json index 2f1390411f97b..11b711d8f7464 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_port_8000_activity_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_port_8000_activity_to_the_internet.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_pptp_point_to_point_tunneling_protocol_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_pptp_point_to_point_tunneling_protocol_activity.json index f7170d8d33a51..87d37b77f53b4 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_pptp_point_to_point_tunneling_protocol_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_pptp_point_to_point_tunneling_protocol_activity.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_proxy_port_activity_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_proxy_port_activity_to_the_internet.json index da4319cf15307..35ba1ca806296 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_proxy_port_activity_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_proxy_port_activity_to_the_internet.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json index d3b65a36f084b..7b0c9b2927cab 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json @@ -65,4 +65,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_to_the_internet.json index 79618a867c73f..17d00ebff4603 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_to_the_internet.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_from_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_from_the_internet.json index da1e46750f3bd..719d0e39e94cd 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_from_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_from_the_internet.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_to_the_internet.json index d07d19b8fffee..a7791047cab26 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_to_the_internet.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_smb_windows_file_sharing_activity_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_smb_windows_file_sharing_activity_to_the_internet.json index 93a4b0ebbbd8e..eca200e318c42 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_smb_windows_file_sharing_activity_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_smb_windows_file_sharing_activity_to_the_internet.json @@ -47,4 +47,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_smtp_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_smtp_to_the_internet.json index ca287605490ef..c05efa1c0e26b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_smtp_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_smtp_to_the_internet.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_sql_server_port_activity_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_sql_server_port_activity_to_the_internet.json index 3a5bd5bff98f5..5ed7ca4112015 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_sql_server_port_activity_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_sql_server_port_activity_to_the_internet.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_from_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_from_the_internet.json index 429a91183e88a..2bd9a3f63ee8c 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_from_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_from_the_internet.json @@ -65,4 +65,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_to_the_internet.json index a260245b4dade..6512a1627db89 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_to_the_internet.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_telnet_port_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_telnet_port_activity.json index 4cfe15683c825..af60c991ceea2 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_telnet_port_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_telnet_port_activity.json @@ -65,4 +65,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_tor_activity_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_tor_activity_to_the_internet.json index 8c8bb809c9fec..ff2ead0eaaf49 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_tor_activity_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_tor_activity_to_the_internet.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_from_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_from_the_internet.json index 4204a4fe62e88..7fac7938579ca 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_from_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_from_the_internet.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_to_the_internet.json index 898282e36df19..0a620d355b9ae 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_to_the_internet.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 3 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/null_user_agent.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/null_user_agent.json index 01246de5595e9..489077c9a5516 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/null_user_agent.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/null_user_agent.json @@ -39,4 +39,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json index 765515ffda27c..c5cf6385afaf0 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json index 79c30c5b38378..4623639b6e8b7 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json index 7b14ad62f6c93..dd14191d30df2 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json index 76767545e794a..386e00054c2cc 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json index 1dc49203f31c1..a68c43b228303 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json index 8ae1b84aaf199..9d9fb5e4a0a8d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "rare_process_by_host_linux_ecs", "name": "Unusual Process For a Linux Host", + "note": "### Investigating an Unusual Linux Process ###\nSignals from this rule indicate the presence of a Linux process that is rare and unusual for the host it ran on. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "ML" ], "type": "machine_learning", - "note": "### Investigating an Unusual Linux Process ###\nSignals from this rule indicate the presence of a Linux process that is rare and unusual for the host it ran on. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json index 879cee388f5dd..0c1d097a73dc2 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "rare_process_by_host_windows_ecs", "name": "Unusual Process For a Windows Host", + "note": "### Investigating an Unusual Windows Process ###\nSignals from this rule indicate the presence of a Windows process that is rare and unusual for the host it ran on. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process metadata like the values of the Company, Description and Product fields which may indicate whether the program is associated with an expected software vendor or package. \n- Examine arguments and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "Windows" ], "type": "machine_learning", - "note": "### Investigating an Unusual Windows Process ###\nSignals from this rule indicate the presence of a Windows process that is rare and unusual for the host it ran on. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process metadata like the values of the Company, Description and Product fields which may indicate whether the program is associated with an expected software vendor or package. \n- Examine arguments and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/sqlmap_user_agent.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/sqlmap_user_agent.json index 10412c19da1b1..3ad82d14be7a7 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/sqlmap_user_agent.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/sqlmap_user_agent.json @@ -21,4 +21,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json index 4b94fdc6da147..b3c3f2d76a8c9 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json index 1092bcb20bcc3..0a85fee3de436 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "windows_anomalous_network_activity_ecs", "name": "Unusual Windows Network Activity", + "note": "### Investigating Unusual Network Activity ###\nSignals from this rule indicate the presence of network activity from a Windows process for which network activity is very unusual. Here are some possible avenues of investigation:\n- Consider the IP addresses, protocol and ports. Are these used by normal but infrequent network workflows? Are they expected or unexpected? \n- If the destination IP address is remote or external, does it associate with an expected domain, organization or geography? Note: avoid interacting directly with suspected malicious IP addresses.\n- Consider the user as identified by the username field. Is this network activity part of an expected workflow for the user who ran the program?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools.", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "Windows" ], "type": "machine_learning", - "note": "### Investigating Unusual Network Activity ###\nSignals from this rule indicate the presence of network activity from a Windows process for which network activity is very unusual. Here are some possible avenues of investigation:\n- Consider the IP addresses, protocol and ports. Are these used by normal but infrequent network workflows? Are they expected or unexpected? \n- If the destination IP address is remote or external, does it associate with an expected domain, organization or geography? Note: avoid interacting directly with suspected malicious IP addresses.\n- Consider the user as identified by the username field. Is this network activity part of an expected workflow for the user who ran the program?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json index 8a88607b9d5c9..2652915d21d85 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json index f9adfeb830618..4e70426a4faf8 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "windows_anomalous_process_all_hosts_ecs", "name": "Anomalous Process For a Windows Population", + "note": "### Investigating an Unusual Windows Process ###\nSignals from this rule indicate the presence of a Windows process that is rare and unusual for all of the Windows hosts for which Winlogbeat data is available. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process metadata like the values of the Company, Description and Product fields which may indicate whether the program is associated with an expected software vendor or package. \n- Examine arguments and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "Windows" ], "type": "machine_learning", - "note": "### Investigating an Unusual Windows Process ###\nSignals from this rule indicate the presence of a Windows process that is rare and unusual for all of the Windows hosts for which Winlogbeat data is available. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process metadata like the values of the Company, Description and Product fields which may indicate whether the program is associated with an expected software vendor or package. \n- Examine arguments and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json index 98a078ccea4a4..4742fd951f471 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json index 564ca1782526f..bc38877a00ad0 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json index afef569f4ebb4..92c4b22823120 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json index a0c6ff5c938f1..9ad05eda8f518 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "windows_anomalous_user_name_ecs", "name": "Unusual Windows Username", + "note": "### Investigating an Unusual Windows User ###\nSignals from this rule indicate activity for a Windows user name that is rare and unusual. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host? Could this be related to occasional troubleshooting or support activity?\n- Examine the history of user activity. If this user manifested only very recently, it might be a service account for a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks that the user is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "Windows" ], "type": "machine_learning", - "note": "### Investigating an Unusual Windows User ###\nSignals from this rule indicate activity for a Windows user name that is rare and unusual. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host? Could this be related to occasional troubleshooting or support activity?\n- Examine the history of user activity. If this user manifested only very recently, it might be a service account for a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks that the user is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_certutil_network_connection.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_certutil_network_connection.json index 52a373e3aeb77..82db7de3d3130 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_certutil_network_connection.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_certutil_network_connection.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_prompt_connecting_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_prompt_connecting_to_the_internet.json index 2bee265a74e11..51fceacddb3c9 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_prompt_connecting_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_prompt_connecting_to_the_internet.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_powershell.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_powershell.json index d8f91dba7dd89..8e88549a44ada 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_powershell.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_powershell.json @@ -47,4 +47,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_svchost.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_svchost.json index 6fd194ee2fa22..f36f853a8e760 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_svchost.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_svchost.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_credential_dumping_msbuild.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_credential_dumping_msbuild.json index 43050e2769a24..4ff7891438554 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_credential_dumping_msbuild.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_credential_dumping_msbuild.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_cve_2020_0601.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_cve_2020_0601.json index f5eb37c70d268..b42427a912cbb 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_cve_2020_0601.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_cve_2020_0601.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_defense_evasion_via_filter_manager.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_defense_evasion_via_filter_manager.json index 0e8c5a5f2f631..ba684c4d721ee 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_defense_evasion_via_filter_manager.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_defense_evasion_via_filter_manager.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_office_app.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_office_app.json index 72e02f8718d03..78f34c15bbd31 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_office_app.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_office_app.json @@ -53,4 +53,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_script.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_script.json index ad519f1516aa6..3952a4680a523 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_script.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_script.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_system_process.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_system_process.json index 1bbce904f2518..a2e29c3900144 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_system_process.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_system_process.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_renamed.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_renamed.json index eea4b3b4efe10..1e63b259a86ec 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_renamed.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_renamed.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_unusal_process.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_unusal_process.json index 81ea14e265388..117d5982421a4 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_unusal_process.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_unusal_process.json @@ -38,4 +38,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_compiled_html_file.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_compiled_html_file.json index 7755ff0233f7c..07c87531c4a4a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_compiled_html_file.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_compiled_html_file.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_net_com_assemblies.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_net_com_assemblies.json index d6acb81c10e3f..fb59cff68410e 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_net_com_assemblies.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_net_com_assemblies.json @@ -47,4 +47,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_trusted_developer_utilities.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_trusted_developer_utilities.json index 87e38febb0743..202bfc6b46afc 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_trusted_developer_utilities.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_trusted_developer_utilities.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_html_help_executable_program_connecting_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_html_help_executable_program_connecting_to_the_internet.json index 6c8cd0673256a..906995b3b6662 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_html_help_executable_program_connecting_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_html_help_executable_program_connecting_to_the_internet.json @@ -47,4 +47,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_injection_msbuild.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_injection_msbuild.json index c6310c12ed974..32a8f50c4b911 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_injection_msbuild.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_injection_msbuild.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_misc_lolbin_connecting_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_misc_lolbin_connecting_to_the_internet.json index a0e311d8eb154..361a3e99b4dbd 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_misc_lolbin_connecting_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_misc_lolbin_connecting_to_the_internet.json @@ -47,4 +47,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_modification_of_boot_config.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_modification_of_boot_config.json index 045a9789b1260..66195acafa5cb 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_modification_of_boot_config.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_modification_of_boot_config.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_msxsl_network.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_msxsl_network.json index e80dcde1e398d..735ae0b2d6a7b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_msxsl_network.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_msxsl_network.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_net_command_system_account.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_net_command_system_account.json index c2379142df002..b2770ac2383fd 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_net_command_system_account.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_net_command_system_account.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_persistence_via_application_shimming.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_persistence_via_application_shimming.json index 2f44727f9e6f0..5b77fdb01a605 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_persistence_via_application_shimming.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_persistence_via_application_shimming.json @@ -47,4 +47,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_priv_escalation_via_accessibility_features.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_priv_escalation_via_accessibility_features.json index aeff071ed4514..59ae2f6ad3bb8 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_priv_escalation_via_accessibility_features.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_priv_escalation_via_accessibility_features.json @@ -47,4 +47,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_process_discovery_via_tasklist_command.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_process_discovery_via_tasklist_command.json index 3a883fa51b763..489c8a47561b5 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_process_discovery_via_tasklist_command.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_process_discovery_via_tasklist_command.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json index febaa57443f76..a227b36064a9d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json @@ -21,4 +21,4 @@ ], "type": "machine_learning", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json index 7318364c3aac2..15241d7869c00 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json @@ -8,6 +8,7 @@ "interval": "15m", "machine_learning_job_id": "windows_rare_user_type10_remote_login", "name": "Unusual Windows Remote User", + "note": "### Investigating an Unusual Windows User ###\nSignals from this rule indicate activity for a rare and unusual Windows RDP (remote desktop) user. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is the user part of a group who normally logs into Windows hosts using RDP (remote desktop protocol)? Is this logon activity part of an expected workflow for the user? \n- Consider the source of the login. If the source is remote, could this be related to occasional troubleshooting or support activity by a vendor or an employee working remotely?", "references": [ "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" ], @@ -20,6 +21,5 @@ "Windows" ], "type": "machine_learning", - "note": "### Investigating an Unusual Windows User ###\nSignals from this rule indicate activity for a rare and unusual Windows RDP (remote desktop) user. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is the user part of a group who normally logs into Windows hosts using RDP (remote desktop protocol)? Is this logon activity part of an expected workflow for the user? \n- Consider the source of the login. If the source is remote, could this be related to occasional troubleshooting or support activity by a vendor or an employee working remotely?", "version": 1 } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_register_server_program_connecting_to_the_internet.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_register_server_program_connecting_to_the_internet.json index 1e061f2ef9463..f6fc38f963640 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_register_server_program_connecting_to_the_internet.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_register_server_program_connecting_to_the_internet.json @@ -50,4 +50,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_pdf_reader.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_pdf_reader.json index 9d4c2438acfb9..6c2b167a76ee4 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_pdf_reader.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_pdf_reader.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_uac_bypass_event_viewer.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_uac_bypass_event_viewer.json index df7a6fe1285d1..1fb44f0c842de 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_uac_bypass_event_viewer.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_uac_bypass_event_viewer.json @@ -32,4 +32,4 @@ ], "type": "query", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_whoami_command_activity.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_whoami_command_activity.json index 93ce1f83dd64e..c01396dd51527 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_whoami_command_activity.json +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_whoami_command_activity.json @@ -35,4 +35,4 @@ ], "type": "query", "version": 2 -} \ No newline at end of file +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/find_ml_signals.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/find_ml_signals.ts index 8ac5a6cde39cc..342976f3fd0fc 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/find_ml_signals.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/find_ml_signals.ts @@ -6,24 +6,35 @@ import dateMath from '@elastic/datemath'; -import { AlertServices } from '../../../../../alerting/server'; - +import { APICaller, KibanaRequest } from '../../../../../../../src/core/server'; +import { MlPluginSetup } from '../../../../../ml/server'; import { getAnomalies } from '../../machine_learning'; -export const findMlSignals = async ( - jobId: string, - anomalyThreshold: number, - from: string, - to: string, - callCluster: AlertServices['callCluster'] -) => { +export const findMlSignals = async ({ + ml, + callCluster, + request, + jobId, + anomalyThreshold, + from, + to, +}: { + ml: MlPluginSetup; + callCluster: APICaller; + request: KibanaRequest; + jobId: string; + anomalyThreshold: number; + from: string; + to: string; +}) => { + const { mlSearch } = ml.mlSystemProvider(callCluster, request); const params = { jobIds: [jobId], threshold: anomalyThreshold, earliestMs: dateMath.parse(from)?.valueOf() ?? 0, latestMs: dateMath.parse(to)?.valueOf() ?? 0, }; - const relevantAnomalies = await getAnomalies(params, callCluster); + const relevantAnomalies = await getAnomalies(params, mlSearch); return relevantAnomalies; }; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 137603741dc8f..ca259b3581720 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -5,7 +5,7 @@ */ import { performance } from 'perf_hooks'; -import { Logger } from 'src/core/server'; +import { Logger, KibanaRequest } from 'src/core/server'; import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; import { isJobStarted, isMlRule } from '../../../../common/detection_engine/ml_helpers'; @@ -138,8 +138,9 @@ export const signalRulesAlertType = ({ ); } + const scopedMlCallCluster = services.getScopedCallCluster(ml.mlClient); const summaryJobs = await ml - .jobServiceProvider(ml.mlClient.callAsInternalUser) + .jobServiceProvider(scopedMlCallCluster) .jobsSummary([machineLearningJobId]); const jobSummary = summaryJobs.find(job => job.id === machineLearningJobId); @@ -155,13 +156,18 @@ export const signalRulesAlertType = ({ await ruleStatusService.error(errorMessage); } - const anomalyResults = await findMlSignals( - machineLearningJobId, + const anomalyResults = await findMlSignals({ + ml, + callCluster: scopedMlCallCluster, + // This is needed to satisfy the ML Services API, but can be empty as it is + // currently unused by the mlSearch function. + request: ({} as unknown) as KibanaRequest, + jobId: machineLearningJobId, anomalyThreshold, from, to, - services.callCluster - ); + }); + const anomalyCount = anomalyResults.hits.hits.length; if (anomalyCount) { logger.info(buildRuleMessage(`Found ${anomalyCount} signals from ML anomalies.`)); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/index.test.ts b/x-pack/plugins/siem/server/lib/machine_learning/index.test.ts new file mode 100644 index 0000000000000..35a080f5ade76 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/index.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { getAnomalies, AnomaliesSearchParams } from '.'; + +const getFiltersFromMock = (mock: jest.Mock) => { + const [[searchParams]] = mock.mock.calls; + return searchParams.body.query.bool.filter; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const getBoolCriteriaFromFilters = (filters: any[]) => filters[1].bool.must; + +describe('getAnomalies', () => { + let searchParams: AnomaliesSearchParams; + + beforeEach(() => { + searchParams = { + jobIds: ['jobId1'], + threshold: 5, + earliestMs: 1588517231429, + latestMs: 1588617231429, + }; + }); + + it('calls the provided mlSearch function', () => { + const mockMlSearch = jest.fn(); + getAnomalies(searchParams, mockMlSearch); + + expect(mockMlSearch).toHaveBeenCalled(); + }); + + it('passes anomalyThreshold as part of the query', () => { + const mockMlSearch = jest.fn(); + getAnomalies(searchParams, mockMlSearch); + const filters = getFiltersFromMock(mockMlSearch); + const criteria = getBoolCriteriaFromFilters(filters); + + expect(criteria).toEqual( + expect.arrayContaining([{ range: { record_score: { gte: searchParams.threshold } } }]) + ); + }); + + it('passes time range as part of the query', () => { + const mockMlSearch = jest.fn(); + getAnomalies(searchParams, mockMlSearch); + const filters = getFiltersFromMock(mockMlSearch); + const criteria = getBoolCriteriaFromFilters(filters); + + expect(criteria).toEqual( + expect.arrayContaining([ + { + range: { + timestamp: { + gte: searchParams.earliestMs, + lte: searchParams.latestMs, + format: 'epoch_millis', + }, + }, + }, + ]) + ); + }); + + it('passes a single jobId as part of the query', () => { + const mockMlSearch = jest.fn(); + getAnomalies(searchParams, mockMlSearch); + const filters = getFiltersFromMock(mockMlSearch); + const criteria = getBoolCriteriaFromFilters(filters); + + expect(criteria).toEqual( + expect.arrayContaining([ + { + query_string: { + analyze_wildcard: false, + query: 'job_id:jobId1', + }, + }, + ]) + ); + }); + + it('passes multiple jobIds as part of the query', () => { + const mockMlSearch = jest.fn(); + searchParams.jobIds = ['jobId1', 'jobId2']; + getAnomalies(searchParams, mockMlSearch); + const filters = getFiltersFromMock(mockMlSearch); + const criteria = getBoolCriteriaFromFilters(filters); + + expect(criteria).toEqual( + expect.arrayContaining([ + { + query_string: { + analyze_wildcard: false, + query: 'job_id:jobId1 OR job_id:jobId2', + }, + }, + ]) + ); + }); +}); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/index.ts b/x-pack/plugins/siem/server/lib/machine_learning/index.ts index 35789b5e202e2..eb09fdde3cce3 100644 --- a/x-pack/plugins/siem/server/lib/machine_learning/index.ts +++ b/x-pack/plugins/siem/server/lib/machine_learning/index.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchResponse } from 'elasticsearch'; +import { SearchResponse, SearchParams } from 'elasticsearch'; -import { AlertServices } from '../../../../alerting/server'; import { AnomalyRecordDoc as Anomaly } from '../../../../ml/common/types/anomalies'; export { Anomaly }; export type AnomalyResults = SearchResponse; +type MlSearch = (searchParams: SearchParams) => Promise>; export interface AnomaliesSearchParams { jobIds: string[]; @@ -22,12 +22,11 @@ export interface AnomaliesSearchParams { export const getAnomalies = async ( params: AnomaliesSearchParams, - callCluster: AlertServices['callCluster'] + mlSearch: MlSearch ): Promise => { const boolCriteria = buildCriteria(params); - return callCluster('search', { - index: '.ml-anomalies-*', + return mlSearch({ size: params.maxRecords || 100, body: { query: { diff --git a/x-pack/plugins/siem/server/lib/timeline/convert_saved_object_to_savedtimeline.ts b/x-pack/plugins/siem/server/lib/timeline/convert_saved_object_to_savedtimeline.ts index bde24a338ec84..00fb77bfb1647 100644 --- a/x-pack/plugins/siem/server/lib/timeline/convert_saved_object_to_savedtimeline.ts +++ b/x-pack/plugins/siem/server/lib/timeline/convert_saved_object_to_savedtimeline.ts @@ -11,16 +11,21 @@ import { identity } from 'fp-ts/lib/function'; import { TimelineSavedObjectRuntimeType, TimelineSavedObject, + TimelineType, } from '../../../common/types/timeline'; export const convertSavedObjectToSavedTimeline = (savedObject: unknown): TimelineSavedObject => { const timeline = pipe( TimelineSavedObjectRuntimeType.decode(savedObject), map(savedTimeline => { + const attributes = { + ...savedTimeline.attributes, + timelineType: savedTimeline.attributes.timelineType ?? TimelineType.default, + }; return { savedObjectId: savedTimeline.id, version: savedTimeline.version, - ...savedTimeline.attributes, + ...attributes, }; }), fold(errors => { diff --git a/x-pack/plugins/siem/server/lib/timeline/pick_saved_timeline.ts b/x-pack/plugins/siem/server/lib/timeline/pick_saved_timeline.ts index eeded1cc2532d..6de10bffb1325 100644 --- a/x-pack/plugins/siem/server/lib/timeline/pick_saved_timeline.ts +++ b/x-pack/plugins/siem/server/lib/timeline/pick_saved_timeline.ts @@ -16,6 +16,7 @@ export const pickSavedTimeline = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any => { const dateNow = new Date().valueOf(); + if (timelineId == null) { savedTimeline.created = dateNow; savedTimeline.createdBy = userInfo?.username ?? UNAUTHENTICATED_USER; @@ -27,13 +28,15 @@ export const pickSavedTimeline = ( } if (savedTimeline.timelineType === TimelineType.template) { - savedTimeline.timelineType = TimelineType.template; if (savedTimeline.templateTimelineId == null) { + // create template timeline savedTimeline.templateTimelineId = uuid.v4(); - } - - if (savedTimeline.templateTimelineVersion == null) { savedTimeline.templateTimelineVersion = 1; + } else { + // update template timeline + if (savedTimeline.templateTimelineVersion != null) { + savedTimeline.templateTimelineVersion = savedTimeline.templateTimelineVersion + 1; + } } } else { savedTimeline.timelineType = TimelineType.default; diff --git a/x-pack/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts index 304ca309775ff..2827c7a1c0ac6 100644 --- a/x-pack/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -109,7 +109,7 @@ export const updateTemplateTimelineWithTimelineId = { timeline: { ...inputTemplateTimeline, templateTimelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', - templateTimelineVersion: 2, + templateTimelineVersion: 1, }, timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', version: 'WzEyMjUsMV0=', diff --git a/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts index 56c152d02ae98..11f93a9c48bf6 100644 --- a/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts +++ b/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts @@ -29,6 +29,7 @@ describe('import timelines', () => { let securitySetup: SecurityPluginSetup; let { context } = requestContextMock.createTools(); let mockGetTimeline: jest.Mock; + let mockGetTemplateTimeline: jest.Mock; let mockPersistTimeline: jest.Mock; let mockPersistPinnedEventOnTimeline: jest.Mock; let mockPersistNote: jest.Mock; @@ -51,6 +52,7 @@ describe('import timelines', () => { } as unknown) as SecurityPluginSetup; mockGetTimeline = jest.fn(); + mockGetTemplateTimeline = jest.fn(); mockPersistTimeline = jest.fn(); mockPersistPinnedEventOnTimeline = jest.fn(); mockPersistNote = jest.fn(); @@ -83,6 +85,7 @@ describe('import timelines', () => { jest.doMock('../saved_object', () => { return { getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue(null), persistTimeline: mockPersistTimeline.mockReturnValue({ timeline: { savedObjectId: newTimelineSavedObjectId, version: newTimelineVersion }, }), @@ -212,11 +215,12 @@ describe('import timelines', () => { }); }); - describe('Import a timeline already exist but overwrite is not allowed', () => { + describe('Import a timeline already exist', () => { beforeEach(() => { jest.doMock('../saved_object', () => { return { getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue(null), persistTimeline: mockPersistTimeline, }; }); diff --git a/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts index bff89bdf9b5b2..99621f1391acb 100644 --- a/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts +++ b/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts @@ -38,7 +38,9 @@ import { PromiseFromStreams, timelineSavedObjectOmittedFields, } from './utils/import_timelines'; -import { createTimelines, getTimeline } from './utils/create_timelines'; +import { createTimelines, getTimeline, getTemplateTimeline } from './utils/create_timelines'; +import { TimelineType } from '../../../../common/types/timeline'; +import { checkIsFailureCases } from './utils/update_timelines'; const CHUNK_PARSED_OBJECT_SIZE = 10; @@ -121,6 +123,9 @@ export const importTimelinesRoute = ( pinnedEventIds, globalNotes, eventNotes, + templateTimelineId, + templateTimelineVersion, + timelineType, } = parsedTimeline; const parsedTimelineObject = omit( timelineSavedObjectOmittedFields, @@ -128,9 +133,23 @@ export const importTimelinesRoute = ( ); let newTimeline = null; try { - const timeline = await getTimeline(frameworkRequest, savedObjectId); - - if (timeline == null) { + const templateTimeline = + templateTimelineId != null + ? await getTemplateTimeline(frameworkRequest, templateTimelineId) + : null; + const timeline = + templateTimeline?.savedObjectId != null || savedObjectId != null + ? await getTimeline( + frameworkRequest, + templateTimeline?.savedObjectId ?? savedObjectId + ) + : null; + const isHandlingTemplateTimeline = timelineType === TimelineType.template; + if ( + (timeline == null && !isHandlingTemplateTimeline) || + (templateTimeline == null && isHandlingTemplateTimeline) + ) { + // create timeline / template timeline newTimeline = await createTimelines( frameworkRequest, parsedTimelineObject, @@ -141,6 +160,37 @@ export const importTimelinesRoute = ( [] // existing note ids ); + resolve({ + timeline_id: newTimeline.timeline.savedObjectId, + status_code: 200, + }); + } else if ( + timeline != null && + templateTimeline != null && + isHandlingTemplateTimeline + ) { + // update template timeline + const errorObj = checkIsFailureCases( + isHandlingTemplateTimeline, + timeline.version, + templateTimeline.templateTimelineVersion ?? null, + timeline, + templateTimeline + ); + if (errorObj != null) { + return siemResponse.error(errorObj); + } + + newTimeline = await createTimelines( + frameworkRequest, + { ...parsedTimelineObject, templateTimelineId, templateTimelineVersion }, + timeline.savedObjectId, // timelineSavedObjectId + timeline.version, // timelineVersion + pinnedEventIds, + globalNotes, + [] // existing note ids + ); + resolve({ timeline_id: newTimeline.timeline.savedObjectId, status_code: 200, @@ -150,7 +200,7 @@ export const importTimelinesRoute = ( createBulkErrorObject({ id: savedObjectId, statusCode: 409, - message: `timeline_id: "${savedObjectId}" already exists`, + message: `timeline_id: "${timeline?.savedObjectId}" already exists`, }) ); } diff --git a/x-pack/plugins/siem/server/lib/timeline/routes/update_timelines_route.test.ts b/x-pack/plugins/siem/server/lib/timeline/routes/update_timelines_route.test.ts index 9c47488d47159..2a3feb7afd59c 100644 --- a/x-pack/plugins/siem/server/lib/timeline/routes/update_timelines_route.test.ts +++ b/x-pack/plugins/siem/server/lib/timeline/routes/update_timelines_route.test.ts @@ -215,6 +215,12 @@ describe('update timelines', () => { ); }); + test('should Update existing template timeline with timelineId', async () => { + expect(mockPersistTimeline.mock.calls[0][1]).toEqual( + updateTemplateTimelineWithTimelineId.timelineId + ); + }); + test('should Update existing template timeline with timeline version', async () => { expect(mockPersistTimeline.mock.calls[0][2]).toEqual( updateTemplateTimelineWithTimelineId.version diff --git a/x-pack/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts b/x-pack/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts index 9e120cdc023dc..a49627d40c8f5 100644 --- a/x-pack/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts +++ b/x-pack/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts @@ -96,4 +96,6 @@ export const timelineSavedObjectOmittedFields = [ 'createdBy', 'updated', 'updatedBy', + 'templateTimelineId', + 'templateTimelineVersion', ]; diff --git a/x-pack/plugins/siem/server/lib/timeline/routes/utils/update_timelines.ts b/x-pack/plugins/siem/server/lib/timeline/routes/utils/update_timelines.ts index 6a25d8def9116..a4efa676daddc 100644 --- a/x-pack/plugins/siem/server/lib/timeline/routes/utils/update_timelines.ts +++ b/x-pack/plugins/siem/server/lib/timeline/routes/utils/update_timelines.ts @@ -14,8 +14,7 @@ export const NO_MATCH_VERSION_ERROR_MESSAGE = 'TimelineVersion conflict: The given version doesn not match with existing timeline'; export const NO_MATCH_ID_ERROR_MESSAGE = "Timeline id doesn't match with existing template timeline"; -export const OLDER_VERSION_ERROR_MESSAGE = - 'Template timelineVersion conflict: The given version is older then existing version'; +export const TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE = 'Template timelineVersion conflict'; export const checkIsFailureCases = ( isHandlingTemplateTimeline: boolean, @@ -68,11 +67,11 @@ export const checkIsFailureCases = ( templateTimelineVersion != null && existTemplateTimeline != null && existTemplateTimeline.templateTimelineVersion != null && - existTemplateTimeline.templateTimelineVersion >= templateTimelineVersion + existTemplateTimeline.templateTimelineVersion !== templateTimelineVersion ) { // Throw error you can not update a template timeline version with an old version return { - body: OLDER_VERSION_ERROR_MESSAGE, + body: TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, statusCode: 409, }; } else { diff --git a/x-pack/plugins/spaces/server/saved_objects/mappings.ts b/x-pack/plugins/spaces/server/saved_objects/mappings.ts index 00e1ab732a8a5..3afa7c389927c 100644 --- a/x-pack/plugins/spaces/server/saved_objects/mappings.ts +++ b/x-pack/plugins/spaces/server/saved_objects/mappings.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { deepFreeze } from '../../../../../src/core/utils'; +import { deepFreeze } from '../../../../../src/core/server'; export const SpacesSavedObjectMappings = deepFreeze({ properties: { diff --git a/x-pack/plugins/transform/public/app/common/request.test.ts b/x-pack/plugins/transform/public/app/common/request.test.ts index 4c3fba3bbf8dd..63f1f8b10ad44 100644 --- a/x-pack/plugins/transform/public/app/common/request.test.ts +++ b/x-pack/plugins/transform/public/app/common/request.test.ts @@ -6,7 +6,7 @@ import { PivotGroupByConfig } from '../common'; -import { StepDefineExposedState } from '../sections/create_transform/components/step_define/step_define_form'; +import { StepDefineExposedState } from '../sections/create_transform/components/step_define'; import { StepDetailsExposedState } from '../sections/create_transform/components/step_details/step_details_form'; import { PIVOT_SUPPORTED_GROUP_BY_AGGS } from './pivot_group_by'; diff --git a/x-pack/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts index 7e965dbe802c0..1a69e9f6476b9 100644 --- a/x-pack/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -9,7 +9,7 @@ import { DefaultOperator } from 'elasticsearch'; import { dictionaryToArray } from '../../../common/types/common'; import { SavedSearchQuery } from '../hooks/use_search_items'; -import { StepDefineExposedState } from '../sections/create_transform/components/step_define/step_define_form'; +import { StepDefineExposedState } from '../sections/create_transform/components/step_define'; import { StepDetailsExposedState } from '../sections/create_transform/components/step_details/step_details_form'; import { IndexPattern } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/transform/public/app/common/transform.ts b/x-pack/plugins/transform/public/app/common/transform.ts index 7cf7412283201..41e18ca9d1589 100644 --- a/x-pack/plugins/transform/public/app/common/transform.ts +++ b/x-pack/plugins/transform/public/app/common/transform.ts @@ -39,6 +39,7 @@ export interface CreateRequestBody extends PreviewRequestBody { dest: { index: IndexName; }; + frequency?: string; sync?: { time: { field: string; diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 39341dd1add65..466575a99b2b4 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -32,6 +32,11 @@ export const useApi = () => { body: JSON.stringify(transformConfig), }); }, + updateTransform(transformId: TransformId, transformConfig: any): Promise { + return http.post(`${API_BASE_PATH}transforms/${transformId}/_update`, { + body: JSON.stringify(transformConfig), + }); + }, deleteTransforms(transformsInfo: TransformEndpointRequest[]): Promise { return http.post(`${API_BASE_PATH}delete_transforms`, { body: JSON.stringify(transformsInfo), diff --git a/x-pack/plugins/transform/public/app/hooks/use_documentation_links.ts b/x-pack/plugins/transform/public/app/hooks/use_documentation_links.ts index 7589ee0a3e935..060b228390c39 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_documentation_links.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_documentation_links.ts @@ -19,6 +19,7 @@ export const useDocumentationLinks = () => { esStackOverviewDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elastic-stack-overview/${DOC_LINK_VERSION}/`, esTransform: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/${TRANSFORM_DOC_PATHS.transforms}`, esTransformPivot: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/put-transform.html#put-transform-request-body`, + esTransformUpdate: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/update-transform.html`, mlDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/`, }; }; diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index ff7ca5d42b5f7..853540e19ea6f 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -123,8 +123,6 @@ export const usePivotData = ( tableItems, } = dataGrid; - const previewRequest = getPreviewRequestBody(indexPatternTitle, query, groupByArr, aggsArr); - const getPreviewData = async () => { if (aggsArr.length === 0 || groupByArr.length === 0) { setTableItems([]); @@ -142,6 +140,7 @@ export const usePivotData = ( setStatus(INDEX_STATUS.LOADING); try { + const previewRequest = getPreviewRequestBody(indexPatternTitle, query, groupByArr, aggsArr); const resp = await api.getTransformsPreview(previewRequest); setTableItems(resp.preview); setRowCount(resp.preview.length); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/advanced_pivot_editor.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/advanced_pivot_editor.tsx new file mode 100644 index 0000000000000..983d36a20e87f --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/advanced_pivot_editor.tsx @@ -0,0 +1,69 @@ +/* + * 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 { isEqual } from 'lodash'; +import React, { memo, FC } from 'react'; + +import { EuiCodeEditor, EuiFormRow } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { StepDefineFormHook } from '../step_define'; + +export const AdvancedPivotEditor: FC = memo( + ({ + actions: { convertToJson, setAdvancedEditorConfig, setAdvancedPivotEditorApplyButtonEnabled }, + state: { advancedEditorConfigLastApplied, advancedEditorConfig, xJsonMode }, + }) => { + return ( + + { + setAdvancedEditorConfig(d); + + // Disable the "Apply"-Button if the config hasn't changed. + if (advancedEditorConfigLastApplied === d) { + setAdvancedPivotEditorApplyButtonEnabled(false); + return; + } + + // Try to parse the string passed on from the editor. + // If parsing fails, the "Apply"-Button will be disabled + try { + JSON.parse(convertToJson(d)); + setAdvancedPivotEditorApplyButtonEnabled(true); + } catch (e) { + setAdvancedPivotEditorApplyButtonEnabled(false); + } + }} + setOptions={{ + fontSize: '12px', + }} + theme="textmate" + aria-label={i18n.translate('xpack.transform.stepDefineForm.advancedEditorAriaLabel', { + defaultMessage: 'Advanced pivot editor', + })} + /> + + ); + }, + (prevProps, nextProps) => isEqual(pickProps(prevProps), pickProps(nextProps)) +); + +function pickProps(props: StepDefineFormHook['advancedPivotEditor']) { + return [props.state.advancedEditorConfigLastApplied, props.state.advancedEditorConfig]; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/index.ts similarity index 78% rename from x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/index.ts index c82c82db6f713..340f7d37ba93b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/index.ts @@ -3,4 +3,5 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export { AgentEnrollmentFlyout } from './agent_enrollment_flyout'; + +export { AdvancedPivotEditor } from './advanced_pivot_editor'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor_switch/advanced_pivot_editor_switch.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor_switch/advanced_pivot_editor_switch.tsx new file mode 100644 index 0000000000000..ce155c58bc37c --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor_switch/advanced_pivot_editor_switch.tsx @@ -0,0 +1,69 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { SwitchModal } from '../switch_modal'; + +import { StepDefineFormHook } from '../step_define'; + +export const AdvancedPivotEditorSwitch: FC = ({ + advancedPivotEditor: { + actions: { setAdvancedEditorSwitchModalVisible, toggleAdvancedEditor }, + state: { + advancedEditorConfig, + advancedEditorConfigLastApplied, + isAdvancedEditorSwitchModalVisible, + isAdvancedPivotEditorEnabled, + isAdvancedPivotEditorApplyButtonEnabled, + }, + }, + pivotConfig: { + actions: { setAggList, setGroupByList }, + }, +}) => { + return ( + + + + { + if ( + isAdvancedPivotEditorEnabled && + (isAdvancedPivotEditorApplyButtonEnabled || + advancedEditorConfig !== advancedEditorConfigLastApplied) + ) { + setAdvancedEditorSwitchModalVisible(true); + return; + } + + toggleAdvancedEditor(); + }} + data-test-subj="transformAdvancedPivotEditorSwitch" + /> + {isAdvancedEditorSwitchModalVisible && ( + setAdvancedEditorSwitchModalVisible(false)} + onConfirm={() => { + setAdvancedEditorSwitchModalVisible(false); + toggleAdvancedEditor(); + }} + type={'pivot'} + /> + )} + + + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor_switch/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor_switch/index.ts new file mode 100644 index 0000000000000..377f54e12c03b --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor_switch/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AdvancedPivotEditorSwitch } from './advanced_pivot_editor_switch'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_query_editor_switch/advanced_query_editor_switch.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_query_editor_switch/advanced_query_editor_switch.tsx new file mode 100644 index 0000000000000..66234b8cc2007 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_query_editor_switch/advanced_query_editor_switch.tsx @@ -0,0 +1,76 @@ +/* + * 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 { EuiSwitch } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { SwitchModal } from '../switch_modal'; +import { defaultSearch } from '../step_define'; + +import { StepDefineFormHook } from '../step_define'; + +export const AdvancedQueryEditorSwitch: FC = ({ + advancedSourceEditor: { + actions: { + setAdvancedSourceEditorSwitchModalVisible, + setSourceConfigUpdated, + toggleAdvancedSourceEditor, + }, + state: { + isAdvancedSourceEditorEnabled, + isAdvancedSourceEditorSwitchModalVisible, + sourceConfigUpdated, + }, + }, + searchBar: { + actions: { setSearchQuery }, + }, +}) => { + // If switching to KQL after updating via editor - reset search + const toggleEditorHandler = (reset = false) => { + if (reset === true) { + setSearchQuery(defaultSearch); + setSourceConfigUpdated(false); + } + toggleAdvancedSourceEditor(reset); + }; + + return ( + <> + { + if (isAdvancedSourceEditorEnabled && sourceConfigUpdated) { + setAdvancedSourceEditorSwitchModalVisible(true); + return; + } + + toggleEditorHandler(); + }} + data-test-subj="transformAdvancedQueryEditorSwitch" + /> + {isAdvancedSourceEditorSwitchModalVisible && ( + setAdvancedSourceEditorSwitchModalVisible(false)} + onConfirm={() => { + setAdvancedSourceEditorSwitchModalVisible(false); + toggleEditorHandler(true); + }} + type={'source'} + /> + )} + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_query_editor_switch/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_query_editor_switch/index.ts new file mode 100644 index 0000000000000..36474e99c66ba --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_query_editor_switch/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AdvancedQueryEditorSwitch } from './advanced_query_editor_switch'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/advanced_source_editor.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/advanced_source_editor.tsx new file mode 100644 index 0000000000000..fecf4972330e1 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/advanced_source_editor.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; + +import { EuiCodeEditor } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { StepDefineFormHook } from '../step_define'; + +export const AdvancedSourceEditor: FC = ({ + searchBar: { + actions: { setSearchString }, + }, + advancedSourceEditor: { + actions: { setAdvancedEditorSourceConfig, setAdvancedSourceEditorApplyButtonEnabled }, + state: { advancedEditorSourceConfig, advancedEditorSourceConfigLastApplied }, + }, +}) => { + return ( + { + setSearchString(undefined); + setAdvancedEditorSourceConfig(d); + + // Disable the "Apply"-Button if the config hasn't changed. + if (advancedEditorSourceConfigLastApplied === d) { + setAdvancedSourceEditorApplyButtonEnabled(false); + return; + } + + // Try to parse the string passed on from the editor. + // If parsing fails, the "Apply"-Button will be disabled + try { + JSON.parse(d); + setAdvancedSourceEditorApplyButtonEnabled(true); + } catch (e) { + setAdvancedSourceEditorApplyButtonEnabled(false); + } + }} + setOptions={{ + fontSize: '12px', + }} + theme="textmate" + aria-label={i18n.translate('xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel', { + defaultMessage: 'Advanced query editor', + })} + /> + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/index.ts new file mode 100644 index 0000000000000..8f5c88c5b44eb --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AdvancedSourceEditor } from './advanced_source_editor'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx index 157e0f76856c8..e5381f09713b5 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx @@ -23,6 +23,7 @@ export const DropDown: React.FC = ({ }) => { return ( = memo( + ({ + actions: { + addAggregation, + addGroupBy, + deleteAggregation, + deleteGroupBy, + updateAggregation, + updateGroupBy, + }, + state: { aggList, aggOptions, aggOptionsData, groupByList, groupByOptions, groupByOptionsData }, + }) => { + return ( + <> + + <> + + + + + + + <> + + + + + + ); + }, + (prevProps, nextProps) => { + return isEqual(prevProps.state, nextProps.state); + } +); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/index.ts new file mode 100644 index 0000000000000..4e1cf81eef98e --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SourceSearchBar } from './source_search_bar'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx new file mode 100644 index 0000000000000..a8e1bf3552c80 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx @@ -0,0 +1,68 @@ +/* + * 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 { EuiCode, EuiInputPopover } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { QueryStringInput } from '../../../../../../../../../src/plugins/data/public'; + +import { SearchItems } from '../../../../hooks/use_search_items'; + +import { StepDefineFormHook, QUERY_LANGUAGE_KUERY } from '../step_define'; + +interface SourceSearchBarProps { + indexPattern: SearchItems['indexPattern']; + searchBar: StepDefineFormHook['searchBar']; +} +export const SourceSearchBar: FC = ({ indexPattern, searchBar }) => { + const { + actions: { searchChangeHandler, searchSubmitHandler, setErrorMessage }, + state: { errorMessage, searchInput }, + } = searchBar; + + return ( + setErrorMessage(undefined)} + input={ + + } + isOpen={errorMessage?.query === searchInput.query && errorMessage?.message !== ''} + > + + {i18n.translate('xpack.transform.stepDefineForm.invalidKuerySyntaxErrorMessageQueryBar', { + defaultMessage: 'Invalid query: {errorMessage}', + values: { + errorMessage: errorMessage?.message.split('\n')[0], + }, + })} + + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts new file mode 100644 index 0000000000000..bda1efe97837f --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts @@ -0,0 +1,71 @@ +/* + * 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 { isEqual } from 'lodash'; + +import { + matchAllQuery, + PivotAggsConfig, + PivotAggsConfigDict, + PivotGroupByConfig, + PivotGroupByConfigDict, + TransformPivotConfig, + PIVOT_SUPPORTED_AGGS, + PIVOT_SUPPORTED_GROUP_BY_AGGS, +} from '../../../../../common'; +import { Dictionary } from '../../../../../../../common/types/common'; + +import { StepDefineExposedState } from './types'; + +export function applyTransformConfigToDefineState( + state: StepDefineExposedState, + transformConfig?: TransformPivotConfig +): StepDefineExposedState { + // apply the transform configuration to wizard DEFINE state + if (transformConfig !== undefined) { + // transform aggregations config to wizard state + state.aggList = Object.keys(transformConfig.pivot.aggregations).reduce((aggList, aggName) => { + const aggConfig = transformConfig.pivot.aggregations[aggName] as Dictionary; + const agg = Object.keys(aggConfig)[0]; + aggList[aggName] = { + ...aggConfig[agg], + agg: agg as PIVOT_SUPPORTED_AGGS, + aggName, + dropDownName: aggName, + } as PivotAggsConfig; + return aggList; + }, {} as PivotAggsConfigDict); + + // transform group by config to wizard state + state.groupByList = Object.keys(transformConfig.pivot.group_by).reduce( + (groupByList, groupByName) => { + const groupByConfig = transformConfig.pivot.group_by[groupByName] as Dictionary; + const groupBy = Object.keys(groupByConfig)[0]; + groupByList[groupByName] = { + agg: groupBy as PIVOT_SUPPORTED_GROUP_BY_AGGS, + aggName: groupByName, + dropDownName: groupByName, + ...groupByConfig[groupBy], + } as PivotGroupByConfig; + return groupByList; + }, + {} as PivotGroupByConfigDict + ); + + // only apply the query from the transform config to wizard state if it's not the default query + const query = transformConfig.source.query; + if (query !== undefined && !isEqual(query, matchAllQuery)) { + state.isAdvancedSourceEditorEnabled = true; + state.searchQuery = query; + state.sourceConfigUpdated = true; + } + + // applying a transform config to wizard state will always result in a valid configuration + state.valid = true; + } + + return state; +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts similarity index 95% rename from x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index 58ab4a1b8ac33..4fac3dce3de44 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getPivotDropdownOptions } from './common'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; +import { getPivotDropdownOptions } from '../common'; +import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; describe('Transform: Define Pivot Common', () => { test('getPivotDropdownOptions()', () => { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/constants.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/constants.ts new file mode 100644 index 0000000000000..4eefa7c94464d --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/constants.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const defaultSearch = '*'; + +export const QUERY_LANGUAGE_KUERY = 'kuery'; +export const QUERY_LANGUAGE_LUCENE = 'lucene'; +export type QUERY_LANGUAGE = 'kuery' | 'lucene'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_name_conflict_toast_messages.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_name_conflict_toast_messages.ts new file mode 100644 index 0000000000000..cad3ab8c71a22 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_name_conflict_toast_messages.ts @@ -0,0 +1,100 @@ +/* + * 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 { AggName, PivotAggsConfigDict, PivotGroupByConfigDict } from '../../../../../common'; + +export function getAggNameConflictToastMessages( + aggName: AggName, + aggList: PivotAggsConfigDict, + groupByList: PivotGroupByConfigDict +): string[] { + if (aggList[aggName] !== undefined) { + return [ + i18n.translate('xpack.transform.stepDefineForm.aggExistsErrorMessage', { + defaultMessage: `An aggregation configuration with the name '{aggName}' already exists.`, + values: { aggName }, + }), + ]; + } + + if (groupByList[aggName] !== undefined) { + return [ + i18n.translate('xpack.transform.stepDefineForm.groupByExistsErrorMessage', { + defaultMessage: `A group by configuration with the name '{aggName}' already exists.`, + values: { aggName }, + }), + ]; + } + + const conflicts: string[] = []; + + // check the new aggName against existing aggs and groupbys + const aggNameSplit = aggName.split('.'); + let aggNameCheck: string; + aggNameSplit.forEach(aggNamePart => { + aggNameCheck = aggNameCheck === undefined ? aggNamePart : `${aggNameCheck}.${aggNamePart}`; + if (aggList[aggNameCheck] !== undefined || groupByList[aggNameCheck] !== undefined) { + conflicts.push( + i18n.translate('xpack.transform.stepDefineForm.nestedConflictErrorMessage', { + defaultMessage: `Couldn't add configuration '{aggName}' because of a nesting conflict with '{aggNameCheck}'.`, + values: { aggName, aggNameCheck }, + }) + ); + } + }); + + if (conflicts.length > 0) { + return conflicts; + } + + // check all aggs against new aggName + aggListNameLoop: for (const aggListName of Object.keys(aggList)) { + const aggListNameSplit = aggListName.split('.'); + let aggListNameCheck: string | undefined; + for (const aggListNamePart of aggListNameSplit) { + aggListNameCheck = + aggListNameCheck === undefined ? aggListNamePart : `${aggListNameCheck}.${aggListNamePart}`; + if (aggListNameCheck === aggName) { + conflicts.push( + i18n.translate('xpack.transform.stepDefineForm.nestedAggListConflictErrorMessage', { + defaultMessage: `Couldn't add configuration '{aggName}' because of a nesting conflict with '{aggListName}'.`, + values: { aggName, aggListName }, + }) + ); + break aggListNameLoop; + } + } + } + + if (conflicts.length > 0) { + return conflicts; + } + + // check all group-bys against new aggName + groupByListNameLoop: for (const groupByListName of Object.keys(groupByList)) { + const groupByListNameSplit = groupByListName.split('.'); + let groupByListNameCheck: string | undefined; + for (const groupByListNamePart of groupByListNameSplit) { + groupByListNameCheck = + groupByListNameCheck === undefined + ? groupByListNamePart + : `${groupByListNameCheck}.${groupByListNamePart}`; + if (groupByListNameCheck === aggName) { + conflicts.push( + i18n.translate('xpack.transform.stepDefineForm.nestedGroupByListConflictErrorMessage', { + defaultMessage: `Couldn't add configuration '{aggName}' because of a nesting conflict with '{groupByListName}'.`, + values: { aggName, groupByListName }, + }) + ); + break groupByListNameLoop; + } + } + } + + return conflicts; +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts new file mode 100644 index 0000000000000..263a8954c96eb --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts @@ -0,0 +1,37 @@ +/* + * 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 { + EsFieldName, + PERCENTILES_AGG_DEFAULT_PERCENTS, + PivotAggsConfigWithUiSupport, + PIVOT_SUPPORTED_AGGS, +} from '../../../../../common'; + +export function getDefaultAggregationConfig( + aggName: string, + dropDownName: string, + fieldName: EsFieldName, + agg: PIVOT_SUPPORTED_AGGS +): PivotAggsConfigWithUiSupport { + switch (agg) { + case PIVOT_SUPPORTED_AGGS.PERCENTILES: + return { + agg, + aggName, + dropDownName, + field: fieldName, + percents: PERCENTILES_AGG_DEFAULT_PERCENTS, + }; + default: + return { + agg, + aggName, + dropDownName, + field: fieldName, + }; + } +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_group_by_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_group_by_config.ts new file mode 100644 index 0000000000000..712a745ff6e77 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_group_by_config.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EsFieldName, + GroupByConfigWithUiSupport, + PIVOT_SUPPORTED_GROUP_BY_AGGS, +} from '../../../../../common'; + +export function getDefaultGroupByConfig( + aggName: string, + dropDownName: string, + fieldName: EsFieldName, + groupByAgg: PIVOT_SUPPORTED_GROUP_BY_AGGS +): GroupByConfigWithUiSupport { + switch (groupByAgg) { + case PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS: + return { + agg: groupByAgg, + aggName, + dropDownName, + field: fieldName, + }; + case PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM: + return { + agg: groupByAgg, + aggName, + dropDownName, + field: fieldName, + interval: '10', + }; + case PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM: + return { + agg: groupByAgg, + aggName, + dropDownName, + field: fieldName, + calendar_interval: '1m', + }; + } +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_step_define_state.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_step_define_state.ts new file mode 100644 index 0000000000000..30e1659e9e6ab --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_step_define_state.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PivotAggsConfigDict, PivotGroupByConfigDict } from '../../../../../common'; +import { SearchItems } from '../../../../../hooks/use_search_items'; + +import { defaultSearch, QUERY_LANGUAGE_KUERY } from './constants'; +import { StepDefineExposedState } from './types'; + +export function getDefaultStepDefineState(searchItems: SearchItems): StepDefineExposedState { + return { + aggList: {} as PivotAggsConfigDict, + groupByList: {} as PivotGroupByConfigDict, + isAdvancedPivotEditorEnabled: false, + isAdvancedSourceEditorEnabled: false, + searchLanguage: QUERY_LANGUAGE_KUERY, + searchString: undefined, + searchQuery: searchItems.savedSearch !== undefined ? searchItems.combinedQuery : defaultSearch, + sourceConfigUpdated: false, + valid: false, + }; +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts similarity index 64% rename from x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index 65cea40276da9..f916afa921c12 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -3,87 +3,26 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { get } from 'lodash'; import { EuiComboBoxOptionOption } from '@elastic/eui'; -import { IndexPattern, KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/public'; +import { + IndexPattern, + KBN_FIELD_TYPES, +} from '../../../../../../../../../../src/plugins/data/public'; import { DropDownLabel, DropDownOption, - EsFieldName, - GroupByConfigWithUiSupport, - PERCENTILES_AGG_DEFAULT_PERCENTS, - PivotAggsConfigWithUiSupport, PivotAggsConfigWithUiSupportDict, pivotAggsFieldSupport, PivotGroupByConfigWithUiSupportDict, pivotGroupByFieldSupport, - PIVOT_SUPPORTED_AGGS, - PIVOT_SUPPORTED_GROUP_BY_AGGS, -} from '../../../../common'; - -export interface Field { - name: EsFieldName; - type: KBN_FIELD_TYPES; -} +} from '../../../../../common'; -function getDefaultGroupByConfig( - aggName: string, - dropDownName: string, - fieldName: EsFieldName, - groupByAgg: PIVOT_SUPPORTED_GROUP_BY_AGGS -): GroupByConfigWithUiSupport { - switch (groupByAgg) { - case PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS: - return { - agg: groupByAgg, - aggName, - dropDownName, - field: fieldName, - }; - case PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM: - return { - agg: groupByAgg, - aggName, - dropDownName, - field: fieldName, - interval: '10', - }; - case PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM: - return { - agg: groupByAgg, - aggName, - dropDownName, - field: fieldName, - calendar_interval: '1m', - }; - } -} - -function getDefaultAggregationConfig( - aggName: string, - dropDownName: string, - fieldName: EsFieldName, - agg: PIVOT_SUPPORTED_AGGS -): PivotAggsConfigWithUiSupport { - switch (agg) { - case PIVOT_SUPPORTED_AGGS.PERCENTILES: - return { - agg, - aggName, - dropDownName, - field: fieldName, - percents: PERCENTILES_AGG_DEFAULT_PERCENTS, - }; - default: - return { - agg, - aggName, - dropDownName, - field: fieldName, - }; - } -} +import { getDefaultAggregationConfig } from './get_default_aggregation_config'; +import { getDefaultGroupByConfig } from './get_default_group_by_config'; +import { Field } from './types'; const illegalEsAggNameChars = /[[\]>]/g; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/index.ts new file mode 100644 index 0000000000000..af351759c84d1 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + defaultSearch, + QUERY_LANGUAGE, + QUERY_LANGUAGE_KUERY, + QUERY_LANGUAGE_LUCENE, +} from './constants'; +export { applyTransformConfigToDefineState } from './apply_transform_config_to_define_state'; +export { getAggNameConflictToastMessages } from './get_agg_name_conflict_toast_messages'; +export { getDefaultAggregationConfig } from './get_default_aggregation_config'; +export { getDefaultGroupByConfig } from './get_default_group_by_config'; +export { getDefaultStepDefineState } from './get_default_step_define_state'; +export { getPivotDropdownOptions } from './get_pivot_dropdown_options'; +export { ErrorMessage, Field, StepDefineExposedState } from './types'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts new file mode 100644 index 0000000000000..56fde98cd4c71 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts @@ -0,0 +1,34 @@ +/* + * 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 { KBN_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; + +import { EsFieldName, PivotAggsConfigDict, PivotGroupByConfigDict } from '../../../../../common'; +import { SavedSearchQuery } from '../../../../../hooks/use_search_items'; + +import { QUERY_LANGUAGE } from './constants'; + +export interface ErrorMessage { + query: string; + message: string; +} + +export interface Field { + name: EsFieldName; + type: KBN_FIELD_TYPES; +} + +export interface StepDefineExposedState { + aggList: PivotAggsConfigDict; + groupByList: PivotGroupByConfigDict; + isAdvancedPivotEditorEnabled: boolean; + isAdvancedSourceEditorEnabled: boolean; + searchLanguage: QUERY_LANGUAGE; + searchString: string | undefined; + searchQuery: string | SavedSearchQuery; + sourceConfigUpdated: boolean; + valid: boolean; +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_pivot_editor.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_pivot_editor.ts new file mode 100644 index 0000000000000..2e92114286599 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_pivot_editor.ts @@ -0,0 +1,76 @@ +/* + * 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 { useEffect, useState } from 'react'; + +import { useXJsonMode } from '../../../../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; + +import { PreviewRequestBody } from '../../../../../common'; + +import { StepDefineExposedState } from '../common'; + +export const useAdvancedPivotEditor = ( + defaults: StepDefineExposedState, + previewRequest: PreviewRequestBody +) => { + const stringifiedPivotConfig = JSON.stringify(previewRequest.pivot, null, 2); + + // Advanced editor for pivot config state + const [isAdvancedEditorSwitchModalVisible, setAdvancedEditorSwitchModalVisible] = useState(false); + + const [ + isAdvancedPivotEditorApplyButtonEnabled, + setAdvancedPivotEditorApplyButtonEnabled, + ] = useState(false); + + const [isAdvancedPivotEditorEnabled, setAdvancedPivotEditorEnabled] = useState( + defaults.isAdvancedPivotEditorEnabled + ); + + const [advancedEditorConfigLastApplied, setAdvancedEditorConfigLastApplied] = useState( + stringifiedPivotConfig + ); + + const { + convertToJson, + setXJson: setAdvancedEditorConfig, + xJson: advancedEditorConfig, + xJsonMode, + } = useXJsonMode(stringifiedPivotConfig); + + useEffect(() => { + setAdvancedEditorConfig(stringifiedPivotConfig); + }, [setAdvancedEditorConfig, stringifiedPivotConfig]); + + const toggleAdvancedEditor = () => { + setAdvancedEditorConfig(advancedEditorConfig); + setAdvancedPivotEditorEnabled(!isAdvancedPivotEditorEnabled); + setAdvancedPivotEditorApplyButtonEnabled(false); + if (isAdvancedPivotEditorEnabled === false) { + setAdvancedEditorConfigLastApplied(advancedEditorConfig); + } + }; + + return { + actions: { + convertToJson, + setAdvancedEditorConfig, + setAdvancedEditorConfigLastApplied, + setAdvancedEditorSwitchModalVisible, + setAdvancedPivotEditorApplyButtonEnabled, + setAdvancedPivotEditorEnabled, + toggleAdvancedEditor, + }, + state: { + advancedEditorConfig, + advancedEditorConfigLastApplied, + isAdvancedEditorSwitchModalVisible, + isAdvancedPivotEditorApplyButtonEnabled, + isAdvancedPivotEditorEnabled, + xJsonMode, + }, + }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_source_editor.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_source_editor.ts new file mode 100644 index 0000000000000..1ea8a45248fb9 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_advanced_source_editor.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; + +import { PreviewRequestBody } from '../../../../../common'; + +import { StepDefineExposedState } from '../common'; + +export const useAdvancedSourceEditor = ( + defaults: StepDefineExposedState, + previewRequest: PreviewRequestBody +) => { + const stringifiedSourceConfig = JSON.stringify(previewRequest.source.query, null, 2); + + // Advanced editor for source config state + const [sourceConfigUpdated, setSourceConfigUpdated] = useState(defaults.sourceConfigUpdated); + + const [ + isAdvancedSourceEditorSwitchModalVisible, + setAdvancedSourceEditorSwitchModalVisible, + ] = useState(false); + + const [isAdvancedSourceEditorEnabled, setAdvancedSourceEditorEnabled] = useState( + defaults.isAdvancedSourceEditorEnabled + ); + + const [ + isAdvancedSourceEditorApplyButtonEnabled, + setAdvancedSourceEditorApplyButtonEnabled, + ] = useState(false); + + const [ + advancedEditorSourceConfigLastApplied, + setAdvancedEditorSourceConfigLastApplied, + ] = useState(stringifiedSourceConfig); + + const [advancedEditorSourceConfig, setAdvancedEditorSourceConfig] = useState( + stringifiedSourceConfig + ); + + const applyAdvancedSourceEditorChanges = () => { + const sourceConfig = JSON.parse(advancedEditorSourceConfig); + const prettySourceConfig = JSON.stringify(sourceConfig, null, 2); + setSourceConfigUpdated(true); + setAdvancedEditorSourceConfig(prettySourceConfig); + setAdvancedEditorSourceConfigLastApplied(prettySourceConfig); + setAdvancedSourceEditorApplyButtonEnabled(false); + }; + + // If switching to KQL after updating via editor - reset search + const toggleAdvancedSourceEditor = (reset = false) => { + if (reset === true) { + setSourceConfigUpdated(false); + } + if (isAdvancedSourceEditorEnabled === false) { + setAdvancedEditorSourceConfigLastApplied(advancedEditorSourceConfig); + } + + setAdvancedSourceEditorEnabled(!isAdvancedSourceEditorEnabled); + setAdvancedSourceEditorApplyButtonEnabled(false); + }; + + return { + actions: { + applyAdvancedSourceEditorChanges, + setAdvancedSourceEditorApplyButtonEnabled, + setAdvancedSourceEditorEnabled, + setAdvancedEditorSourceConfig, + setAdvancedEditorSourceConfigLastApplied, + setAdvancedSourceEditorSwitchModalVisible, + setSourceConfigUpdated, + toggleAdvancedSourceEditor, + }, + state: { + advancedEditorSourceConfig, + advancedEditorSourceConfigLastApplied, + isAdvancedSourceEditorApplyButtonEnabled, + isAdvancedSourceEditorEnabled, + isAdvancedSourceEditorSwitchModalVisible, + sourceConfigUpdated, + }, + }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts new file mode 100644 index 0000000000000..70886e41fef4e --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; + +import { dictionaryToArray } from '../../../../../../../common/types/common'; + +import { useToastNotifications } from '../../../../../app_dependencies'; +import { AggName, DropDownLabel, PivotAggsConfig, PivotGroupByConfig } from '../../../../../common'; + +import { + getAggNameConflictToastMessages, + getPivotDropdownOptions, + StepDefineExposedState, +} from '../common'; +import { StepDefineFormProps } from '../step_define_form'; + +export const usePivotConfig = ( + defaults: StepDefineExposedState, + indexPattern: StepDefineFormProps['searchItems']['indexPattern'] +) => { + const toastNotifications = useToastNotifications(); + + const { + aggOptions, + aggOptionsData, + groupByOptions, + groupByOptionsData, + } = getPivotDropdownOptions(indexPattern); + + // The list of selected group by fields + const [groupByList, setGroupByList] = useState(defaults.groupByList); + + const addGroupBy = (d: DropDownLabel[]) => { + const label: AggName = d[0].label; + const config: PivotGroupByConfig = groupByOptionsData[label]; + const aggName: AggName = config.aggName; + + const aggNameConflictMessages = getAggNameConflictToastMessages(aggName, aggList, groupByList); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach(m => toastNotifications.addDanger(m)); + return; + } + + groupByList[aggName] = config; + setGroupByList({ ...groupByList }); + }; + + const updateGroupBy = (previousAggName: AggName, item: PivotGroupByConfig) => { + const groupByListWithoutPrevious = { ...groupByList }; + delete groupByListWithoutPrevious[previousAggName]; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + item.aggName, + aggList, + groupByListWithoutPrevious + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach(m => toastNotifications.addDanger(m)); + return; + } + + groupByListWithoutPrevious[item.aggName] = item; + setGroupByList(groupByListWithoutPrevious); + }; + + const deleteGroupBy = (aggName: AggName) => { + delete groupByList[aggName]; + setGroupByList({ ...groupByList }); + }; + + // The list of selected aggregations + const [aggList, setAggList] = useState(defaults.aggList); + + const addAggregation = (d: DropDownLabel[]) => { + const label: AggName = d[0].label; + const config: PivotAggsConfig = aggOptionsData[label]; + const aggName: AggName = config.aggName; + + const aggNameConflictMessages = getAggNameConflictToastMessages(aggName, aggList, groupByList); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach(m => toastNotifications.addDanger(m)); + return; + } + + aggList[aggName] = config; + setAggList({ ...aggList }); + }; + + const updateAggregation = (previousAggName: AggName, item: PivotAggsConfig) => { + const aggListWithoutPrevious = { ...aggList }; + delete aggListWithoutPrevious[previousAggName]; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + item.aggName, + aggListWithoutPrevious, + groupByList + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach(m => toastNotifications.addDanger(m)); + return; + } + + aggListWithoutPrevious[item.aggName] = item; + setAggList(aggListWithoutPrevious); + }; + + const deleteAggregation = (aggName: AggName) => { + delete aggList[aggName]; + setAggList({ ...aggList }); + }; + + const pivotAggsArr = dictionaryToArray(aggList); + const pivotGroupByArr = dictionaryToArray(groupByList); + + const valid = pivotGroupByArr.length > 0 && pivotAggsArr.length > 0; + + return { + actions: { + addAggregation, + addGroupBy, + deleteAggregation, + deleteGroupBy, + setAggList, + setGroupByList, + updateAggregation, + updateGroupBy, + }, + state: { + aggList, + aggOptions, + aggOptionsData, + groupByList, + groupByOptions, + groupByOptionsData, + pivotAggsArr, + pivotGroupByArr, + valid, + }, + }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts new file mode 100644 index 0000000000000..9fff49d300575 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; + +import { esKuery, esQuery, Query } from '../../../../../../../../../../src/plugins/data/public'; + +import { getPivotQuery } from '../../../../../common'; + +import { + ErrorMessage, + StepDefineExposedState, + QUERY_LANGUAGE_KUERY, + QUERY_LANGUAGE_LUCENE, + QUERY_LANGUAGE, +} from '../common'; + +import { StepDefineFormProps } from '../step_define_form'; + +export const useSearchBar = ( + defaults: StepDefineExposedState, + indexPattern: StepDefineFormProps['searchItems']['indexPattern'] +) => { + // The internal state of the input query bar updated on every key stroke. + const [searchInput, setSearchInput] = useState({ + query: defaults.searchString || '', + language: defaults.searchLanguage, + }); + + // The state of the input query bar updated on every submit and to be exposed. + const [searchLanguage, setSearchLanguage] = useState( + defaults.searchLanguage + ); + + const [searchString, setSearchString] = useState( + defaults.searchString + ); + + const [searchQuery, setSearchQuery] = useState(defaults.searchQuery); + + const [errorMessage, setErrorMessage] = useState(undefined); + + const searchChangeHandler = (query: Query) => setSearchInput(query); + const searchSubmitHandler = (query: Query) => { + setSearchLanguage(query.language as QUERY_LANGUAGE); + setSearchString(query.query !== '' ? (query.query as string) : undefined); + try { + switch (query.language) { + case QUERY_LANGUAGE_KUERY: + setSearchQuery( + esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(query.query as string), + indexPattern + ) + ); + return; + case QUERY_LANGUAGE_LUCENE: + setSearchQuery(esQuery.luceneStringToDsl(query.query as string)); + return; + } + } catch (e) { + setErrorMessage({ query: query.query as string, message: e.message }); + } + }; + + const pivotQuery = getPivotQuery(searchQuery); + + return { + actions: { + searchChangeHandler, + searchSubmitHandler, + setErrorMessage, + setSearchInput, + setSearchLanguage, + setSearchQuery, + setSearchString, + }, + state: { + errorMessage, + pivotQuery, + searchInput, + searchLanguage, + searchQuery, + searchString, + }, + }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts new file mode 100644 index 0000000000000..fc47a9e3d3477 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts @@ -0,0 +1,87 @@ +/* + * 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 { useEffect } from 'react'; + +import { getPreviewRequestBody } from '../../../../../common'; + +import { getDefaultStepDefineState } from '../common'; + +import { StepDefineFormProps } from '../step_define_form'; + +import { useAdvancedPivotEditor } from './use_advanced_pivot_editor'; +import { useAdvancedSourceEditor } from './use_advanced_source_editor'; +import { usePivotConfig } from './use_pivot_config'; +import { useSearchBar } from './use_search_bar'; + +export type StepDefineFormHook = ReturnType; + +export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefineFormProps) => { + const defaults = { ...getDefaultStepDefineState(searchItems), ...overrides }; + const { indexPattern } = searchItems; + + const searchBar = useSearchBar(defaults, indexPattern); + const pivotConfig = usePivotConfig(defaults, indexPattern); + + const previewRequest = getPreviewRequestBody( + indexPattern.title, + searchBar.state.pivotQuery, + pivotConfig.state.pivotGroupByArr, + pivotConfig.state.pivotAggsArr + ); + + // pivot config hook + const advancedPivotEditor = useAdvancedPivotEditor(defaults, previewRequest); + + // source config hook + const advancedSourceEditor = useAdvancedSourceEditor(defaults, previewRequest); + + useEffect(() => { + if (!advancedSourceEditor.state.isAdvancedSourceEditorEnabled) { + const previewRequestUpdate = getPreviewRequestBody( + indexPattern.title, + searchBar.state.pivotQuery, + pivotConfig.state.pivotGroupByArr, + pivotConfig.state.pivotAggsArr + ); + + const stringifiedSourceConfigUpdate = JSON.stringify( + previewRequestUpdate.source.query, + null, + 2 + ); + + advancedSourceEditor.actions.setAdvancedEditorSourceConfig(stringifiedSourceConfigUpdate); + } + + onChange({ + aggList: pivotConfig.state.aggList, + groupByList: pivotConfig.state.groupByList, + isAdvancedPivotEditorEnabled: advancedPivotEditor.state.isAdvancedPivotEditorEnabled, + isAdvancedSourceEditorEnabled: advancedSourceEditor.state.isAdvancedSourceEditorEnabled, + searchLanguage: searchBar.state.searchLanguage, + searchString: searchBar.state.searchString, + searchQuery: searchBar.state.searchQuery, + sourceConfigUpdated: advancedSourceEditor.state.sourceConfigUpdated, + valid: pivotConfig.state.valid, + }); + // custom comparison + /* eslint-disable react-hooks/exhaustive-deps */ + }, [ + JSON.stringify(advancedPivotEditor.state), + JSON.stringify(advancedSourceEditor.state), + JSON.stringify(pivotConfig.state), + JSON.stringify(searchBar.state), + /* eslint-enable react-hooks/exhaustive-deps */ + ]); + + return { + advancedPivotEditor, + advancedSourceEditor, + pivotConfig, + searchBar, + }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/index.ts index 881e8c6b26658..b73e281b057e9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/index.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/index.ts @@ -5,9 +5,12 @@ */ export { + defaultSearch, applyTransformConfigToDefineState, getDefaultStepDefineState, StepDefineExposedState, - StepDefineForm, -} from './step_define_form'; + QUERY_LANGUAGE_KUERY, +} from './common'; +export { StepDefineFormHook } from './hooks/use_step_define_form'; +export { StepDefineForm } from './step_define_form'; export { StepDefineSummary } from './step_define_summary'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index a15e958c16b73..bcd2900621a59 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -24,7 +24,8 @@ import { } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; -import { StepDefineForm, getAggNameConflictToastMessages } from './step_define_form'; +import { getAggNameConflictToastMessages } from './common'; +import { StepDefineForm } from './step_define_form'; jest.mock('../../../../../shared_imports'); jest.mock('../../../../../app/app_dependencies'); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 0e6e2c1a38d0e..33adc6781c158 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -4,37 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash'; -import React, { Fragment, FC, useEffect, useState } from 'react'; +import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, - EuiCodeEditor, - EuiCode, - EuiInputPopover, + EuiButtonIcon, + EuiCopy, EuiFlexGroup, EuiFlexItem, EuiForm, - EuiFormHelpText, EuiFormRow, EuiHorizontalRule, EuiLink, - EuiPanel, EuiSpacer, - EuiSwitch, + EuiText, } from '@elastic/eui'; -import { - esKuery, - esQuery, - Query, - QueryStringInput, -} from '../../../../../../../../../src/plugins/data/public'; - -import { useXJsonMode } from '../../../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; - import { DataGrid } from '../../../../../shared_imports'; import { @@ -42,385 +29,62 @@ import { getPivotPreviewDevConsoleStatement, } from '../../../../common/data_grid'; -import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; -import { SavedSearchQuery, SearchItems } from '../../../../hooks/use_search_items'; -import { useIndexData } from '../../../../hooks/use_index_data'; -import { usePivotData } from '../../../../hooks/use_pivot_data'; -import { useToastNotifications } from '../../../../app_dependencies'; -import { dictionaryToArray, Dictionary } from '../../../../../../common/types/common'; import { - getPivotQuery, getPreviewRequestBody, - matchAllQuery, - AggName, - DropDownLabel, PivotAggDict, - PivotAggsConfig, PivotAggsConfigDict, PivotGroupByDict, - PivotGroupByConfig, PivotGroupByConfigDict, PivotSupportedGroupByAggs, - TransformPivotConfig, PIVOT_SUPPORTED_AGGS, - PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; +import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; +import { useIndexData } from '../../../../hooks/use_index_data'; +import { usePivotData } from '../../../../hooks/use_pivot_data'; +import { useToastNotifications } from '../../../../app_dependencies'; +import { SearchItems } from '../../../../hooks/use_search_items'; -import { DropDown } from '../aggregation_dropdown'; -import { AggListForm } from '../aggregation_list'; -import { GroupByListForm } from '../group_by_list'; - -import { getPivotDropdownOptions } from './common'; -import { SwitchModal } from './switch_modal'; - -export interface StepDefineExposedState { - aggList: PivotAggsConfigDict; - groupByList: PivotGroupByConfigDict; - isAdvancedPivotEditorEnabled: boolean; - isAdvancedSourceEditorEnabled: boolean; - searchLanguage: QUERY_LANGUAGE; - searchString: string | undefined; - searchQuery: string | SavedSearchQuery; - sourceConfigUpdated: boolean; - valid: boolean; -} - -interface ErrorMessage { - query: string; - message: string; -} - -const defaultSearch = '*'; - -const QUERY_LANGUAGE_KUERY = 'kuery'; -const QUERY_LANGUAGE_LUCENE = 'lucene'; -type QUERY_LANGUAGE = 'kuery' | 'lucene'; - -export function getDefaultStepDefineState(searchItems: SearchItems): StepDefineExposedState { - return { - aggList: {} as PivotAggsConfigDict, - groupByList: {} as PivotGroupByConfigDict, - isAdvancedPivotEditorEnabled: false, - isAdvancedSourceEditorEnabled: false, - searchLanguage: QUERY_LANGUAGE_KUERY, - searchString: undefined, - searchQuery: searchItems.savedSearch !== undefined ? searchItems.combinedQuery : defaultSearch, - sourceConfigUpdated: false, - valid: false, - }; -} - -export function applyTransformConfigToDefineState( - state: StepDefineExposedState, - transformConfig?: TransformPivotConfig -): StepDefineExposedState { - // apply the transform configuration to wizard DEFINE state - if (transformConfig !== undefined) { - // transform aggregations config to wizard state - state.aggList = Object.keys(transformConfig.pivot.aggregations).reduce((aggList, aggName) => { - const aggConfig = transformConfig.pivot.aggregations[aggName] as Dictionary; - const agg = Object.keys(aggConfig)[0]; - aggList[aggName] = { - ...aggConfig[agg], - agg: agg as PIVOT_SUPPORTED_AGGS, - aggName, - dropDownName: aggName, - } as PivotAggsConfig; - return aggList; - }, {} as PivotAggsConfigDict); - - // transform group by config to wizard state - state.groupByList = Object.keys(transformConfig.pivot.group_by).reduce( - (groupByList, groupByName) => { - const groupByConfig = transformConfig.pivot.group_by[groupByName] as Dictionary; - const groupBy = Object.keys(groupByConfig)[0]; - groupByList[groupByName] = { - agg: groupBy as PIVOT_SUPPORTED_GROUP_BY_AGGS, - aggName: groupByName, - dropDownName: groupByName, - ...groupByConfig[groupBy], - } as PivotGroupByConfig; - return groupByList; - }, - {} as PivotGroupByConfigDict - ); - - // only apply the query from the transform config to wizard state if it's not the default query - const query = transformConfig.source.query; - if (query !== undefined && !isEqual(query, matchAllQuery)) { - state.isAdvancedSourceEditorEnabled = true; - state.searchQuery = query; - state.sourceConfigUpdated = true; - } - - // applying a transform config to wizard state will always result in a valid configuration - state.valid = true; - } - - return state; -} - -export function getAggNameConflictToastMessages( - aggName: AggName, - aggList: PivotAggsConfigDict, - groupByList: PivotGroupByConfigDict -): string[] { - if (aggList[aggName] !== undefined) { - return [ - i18n.translate('xpack.transform.stepDefineForm.aggExistsErrorMessage', { - defaultMessage: `An aggregation configuration with the name '{aggName}' already exists.`, - values: { aggName }, - }), - ]; - } - - if (groupByList[aggName] !== undefined) { - return [ - i18n.translate('xpack.transform.stepDefineForm.groupByExistsErrorMessage', { - defaultMessage: `A group by configuration with the name '{aggName}' already exists.`, - values: { aggName }, - }), - ]; - } - - const conflicts: string[] = []; - - // check the new aggName against existing aggs and groupbys - const aggNameSplit = aggName.split('.'); - let aggNameCheck: string; - aggNameSplit.forEach(aggNamePart => { - aggNameCheck = aggNameCheck === undefined ? aggNamePart : `${aggNameCheck}.${aggNamePart}`; - if (aggList[aggNameCheck] !== undefined || groupByList[aggNameCheck] !== undefined) { - conflicts.push( - i18n.translate('xpack.transform.stepDefineForm.nestedConflictErrorMessage', { - defaultMessage: `Couldn't add configuration '{aggName}' because of a nesting conflict with '{aggNameCheck}'.`, - values: { aggName, aggNameCheck }, - }) - ); - } - }); - - if (conflicts.length > 0) { - return conflicts; - } - - // check all aggs against new aggName - Object.keys(aggList).some(aggListName => { - const aggListNameSplit = aggListName.split('.'); - let aggListNameCheck: string; - return aggListNameSplit.some(aggListNamePart => { - aggListNameCheck = - aggListNameCheck === undefined ? aggListNamePart : `${aggListNameCheck}.${aggListNamePart}`; - if (aggListNameCheck === aggName) { - conflicts.push( - i18n.translate('xpack.transform.stepDefineForm.nestedAggListConflictErrorMessage', { - defaultMessage: `Couldn't add configuration '{aggName}' because of a nesting conflict with '{aggListName}'.`, - values: { aggName, aggListName }, - }) - ); - return true; - } - return false; - }); - }); - - if (conflicts.length > 0) { - return conflicts; - } - - // check all group-bys against new aggName - Object.keys(groupByList).some(groupByListName => { - const groupByListNameSplit = groupByListName.split('.'); - let groupByListNameCheck: string; - return groupByListNameSplit.some(groupByListNamePart => { - groupByListNameCheck = - groupByListNameCheck === undefined - ? groupByListNamePart - : `${groupByListNameCheck}.${groupByListNamePart}`; - if (groupByListNameCheck === aggName) { - conflicts.push( - i18n.translate('xpack.transform.stepDefineForm.nestedGroupByListConflictErrorMessage', { - defaultMessage: `Couldn't add configuration '{aggName}' because of a nesting conflict with '{groupByListName}'.`, - values: { aggName, groupByListName }, - }) - ); - return true; - } - return false; - }); - }); +import { AdvancedPivotEditor } from '../advanced_pivot_editor'; +import { AdvancedPivotEditorSwitch } from '../advanced_pivot_editor_switch'; +import { AdvancedQueryEditorSwitch } from '../advanced_query_editor_switch'; +import { AdvancedSourceEditor } from '../advanced_source_editor'; +import { PivotConfiguration } from '../pivot_configuration'; +import { SourceSearchBar } from '../source_search_bar'; - return conflicts; -} +import { StepDefineExposedState } from './common'; +import { useStepDefineForm } from './hooks/use_step_define_form'; -interface Props { +export interface StepDefineFormProps { overrides?: StepDefineExposedState; onChange(s: StepDefineExposedState): void; searchItems: SearchItems; } -export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, searchItems }) => { - const toastNotifications = useToastNotifications(); - const { esQueryDsl, esTransformPivot } = useDocumentationLinks(); - - const defaults = { ...getDefaultStepDefineState(searchItems), ...overrides }; - - // The internal state of the input query bar updated on every key stroke. - const [searchInput, setSearchInput] = useState({ - query: defaults.searchString || '', - language: defaults.searchLanguage, - }); - const [errorMessage, setErrorMessage] = useState(undefined); - - // The state of the input query bar updated on every submit and to be exposed. - const [searchLanguage, setSearchLanguage] = useState( - defaults.searchLanguage - ); - const [searchString, setSearchString] = useState( - defaults.searchString - ); - const [searchQuery, setSearchQuery] = useState(defaults.searchQuery); - +export const StepDefineForm: FC = React.memo(props => { + const { searchItems } = props; const { indexPattern } = searchItems; - const searchChangeHandler = (query: Query) => setSearchInput(query); - const searchSubmitHandler = (query: Query) => { - setSearchLanguage(query.language as QUERY_LANGUAGE); - setSearchString(query.query !== '' ? (query.query as string) : undefined); - try { - switch (query.language) { - case QUERY_LANGUAGE_KUERY: - setSearchQuery( - esKuery.toElasticsearchQuery( - esKuery.fromKueryExpression(query.query as string), - indexPattern - ) - ); - return; - case QUERY_LANGUAGE_LUCENE: - setSearchQuery(esQuery.luceneStringToDsl(query.query as string)); - return; - } - } catch (e) { - setErrorMessage({ query: query.query as string, message: e.message }); - } - }; - - // The list of selected group by fields - const [groupByList, setGroupByList] = useState(defaults.groupByList); + const toastNotifications = useToastNotifications(); + const stepDefineForm = useStepDefineForm(props); const { - groupByOptions, - groupByOptionsData, - aggOptions, - aggOptionsData, - } = getPivotDropdownOptions(indexPattern); - - const addGroupBy = (d: DropDownLabel[]) => { - const label: AggName = d[0].label; - const config: PivotGroupByConfig = groupByOptionsData[label]; - const aggName: AggName = config.aggName; - - const aggNameConflictMessages = getAggNameConflictToastMessages(aggName, aggList, groupByList); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach(m => toastNotifications.addDanger(m)); - return; - } - - groupByList[aggName] = config; - setGroupByList({ ...groupByList }); - }; - - const updateGroupBy = (previousAggName: AggName, item: PivotGroupByConfig) => { - const groupByListWithoutPrevious = { ...groupByList }; - delete groupByListWithoutPrevious[previousAggName]; - - const aggNameConflictMessages = getAggNameConflictToastMessages( - item.aggName, - aggList, - groupByListWithoutPrevious - ); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach(m => toastNotifications.addDanger(m)); - return; - } - - groupByListWithoutPrevious[item.aggName] = item; - setGroupByList({ ...groupByListWithoutPrevious }); - }; - - const deleteGroupBy = (aggName: AggName) => { - delete groupByList[aggName]; - setGroupByList({ ...groupByList }); - }; - - // The list of selected aggregations - const [aggList, setAggList] = useState(defaults.aggList); - - const addAggregation = (d: DropDownLabel[]) => { - const label: AggName = d[0].label; - const config: PivotAggsConfig = aggOptionsData[label]; - const aggName: AggName = config.aggName; - - const aggNameConflictMessages = getAggNameConflictToastMessages(aggName, aggList, groupByList); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach(m => toastNotifications.addDanger(m)); - return; - } - - aggList[aggName] = config; - setAggList({ ...aggList }); - }; - - const updateAggregation = (previousAggName: AggName, item: PivotAggsConfig) => { - const aggListWithoutPrevious = { ...aggList }; - delete aggListWithoutPrevious[previousAggName]; - - const aggNameConflictMessages = getAggNameConflictToastMessages( - item.aggName, - aggListWithoutPrevious, - groupByList - ); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach(m => toastNotifications.addDanger(m)); - return; - } - - aggListWithoutPrevious[item.aggName] = item; - setAggList({ ...aggListWithoutPrevious }); - }; - - const deleteAggregation = (aggName: AggName) => { - delete aggList[aggName]; - setAggList({ ...aggList }); - }; - - const pivotAggsArr = dictionaryToArray(aggList); - const pivotGroupByArr = dictionaryToArray(groupByList); - const pivotQuery = getPivotQuery(searchQuery); - - // Advanced editor for pivot config state - const [isAdvancedEditorSwitchModalVisible, setAdvancedEditorSwitchModalVisible] = useState(false); - const [ + advancedEditorConfig, + isAdvancedPivotEditorEnabled, isAdvancedPivotEditorApplyButtonEnabled, - setAdvancedPivotEditorApplyButtonEnabled, - ] = useState(false); - const [isAdvancedPivotEditorEnabled, setAdvancedPivotEditorEnabled] = useState( - defaults.isAdvancedPivotEditorEnabled - ); - // Advanced editor for source config state - const [sourceConfigUpdated, setSourceConfigUpdated] = useState(defaults.sourceConfigUpdated); - const [ - isAdvancedSourceEditorSwitchModalVisible, - setAdvancedSourceEditorSwitchModalVisible, - ] = useState(false); - const [isAdvancedSourceEditorEnabled, setAdvancedSourceEditorEnabled] = useState( - defaults.isAdvancedSourceEditorEnabled - ); - const [ + } = stepDefineForm.advancedPivotEditor.state; + const { + advancedEditorSourceConfig, + isAdvancedSourceEditorEnabled, isAdvancedSourceEditorApplyButtonEnabled, - setAdvancedSourceEditorApplyButtonEnabled, - ] = useState(false); + } = stepDefineForm.advancedSourceEditor.state; + const { aggList, groupByList, pivotGroupByArr, pivotAggsArr } = stepDefineForm.pivotConfig.state; + const pivotQuery = stepDefineForm.searchBar.state.pivotQuery; + + const indexPreviewProps = { + ...useIndexData(indexPattern, stepDefineForm.searchBar.state.pivotQuery), + dataTestSubj: 'transformIndexPreview', + toastNotifications, + }; const previewRequest = getPreviewRequestBody( indexPattern.title, @@ -428,49 +92,48 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, pivotGroupByArr, pivotAggsArr ); - // pivot config - const stringifiedPivotConfig = JSON.stringify(previewRequest.pivot, null, 2); - const [advancedEditorConfigLastApplied, setAdvancedEditorConfigLastApplied] = useState( - stringifiedPivotConfig - ); - const { - convertToJson, - setXJson: setAdvancedEditorConfig, - xJson: advancedEditorConfig, - xJsonMode, - } = useXJsonMode(stringifiedPivotConfig); + const pivotPreviewProps = { + ...usePivotData(indexPattern.title, pivotQuery, aggList, groupByList), + dataTestSubj: 'transformPivotPreview', + toastNotifications, + }; - useEffect(() => { - setAdvancedEditorConfig(stringifiedPivotConfig); - }, [setAdvancedEditorConfig, stringifiedPivotConfig]); + // TODO This should use the actual value of `indices.query.bool.max_clause_count` + const maxIndexFields = 1024; + const numIndexFields = indexPattern.fields.length; + const disabledQuery = numIndexFields > maxIndexFields; - // source config - const stringifiedSourceConfig = JSON.stringify(previewRequest.source.query, null, 2); - const [ - advancedEditorSourceConfigLastApplied, - setAdvancedEditorSourceConfigLastApplied, - ] = useState(stringifiedSourceConfig); - const [advancedEditorSourceConfig, setAdvancedEditorSourceConfig] = useState( - stringifiedSourceConfig + const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, indexPattern.title); + const copyToClipboardSourceDescription = i18n.translate( + 'xpack.transform.indexPreview.copyClipboardTooltip', + { + defaultMessage: 'Copy Dev Console statement of the index preview to the clipboard.', + } ); - const applyAdvancedSourceEditorChanges = () => { + const copyToClipboardPivot = getPivotPreviewDevConsoleStatement(previewRequest); + const copyToClipboardPivotDescription = i18n.translate( + 'xpack.transform.pivotPreview.copyClipboardTooltip', + { + defaultMessage: 'Copy Dev Console statement of the pivot preview to the clipboard.', + } + ); + + const applySourceChangesHandler = () => { const sourceConfig = JSON.parse(advancedEditorSourceConfig); - const prettySourceConfig = JSON.stringify(sourceConfig, null, 2); - setSearchQuery(sourceConfig); - setSourceConfigUpdated(true); - setAdvancedEditorSourceConfig(prettySourceConfig); - setAdvancedEditorSourceConfigLastApplied(prettySourceConfig); - setAdvancedSourceEditorApplyButtonEnabled(false); + stepDefineForm.searchBar.actions.setSearchQuery(sourceConfig); + stepDefineForm.advancedSourceEditor.actions.applyAdvancedSourceEditorChanges(); }; - const applyAdvancedPivotEditorChanges = () => { - const pivotConfig = JSON.parse(convertToJson(advancedEditorConfig)); + const applyPivotChangesHandler = () => { + const pivot = JSON.parse( + stepDefineForm.advancedPivotEditor.actions.convertToJson(advancedEditorConfig) + ); const newGroupByList: PivotGroupByConfigDict = {}; - if (pivotConfig !== undefined && pivotConfig.group_by !== undefined) { - Object.entries(pivotConfig.group_by).forEach(d => { + if (pivot !== undefined && pivot.group_by !== undefined) { + Object.entries(pivot.group_by).forEach(d => { const aggName = d[0]; const aggConfig = d[1] as PivotGroupByDict; const aggConfigKeys = Object.keys(aggConfig); @@ -483,11 +146,11 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, }; }); } - setGroupByList(newGroupByList); + stepDefineForm.pivotConfig.actions.setGroupByList(newGroupByList); const newAggList: PivotAggsConfigDict = {}; - if (pivotConfig !== undefined && pivotConfig.aggregations !== undefined) { - Object.entries(pivotConfig.aggregations).forEach(d => { + if (pivot !== undefined && pivot.aggregations !== undefined) { + Object.entries(pivot.aggregations).forEach(d => { const aggName = d[0]; const aggConfig = d[1] as PivotAggDict; const aggConfigKeys = Object.keys(aggConfig); @@ -500,459 +163,207 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, }; }); } - setAggList(newAggList); - - setAdvancedEditorConfigLastApplied(advancedEditorConfig); - setAdvancedPivotEditorApplyButtonEnabled(false); - }; - - const toggleAdvancedEditor = () => { - setAdvancedEditorConfig(advancedEditorConfig); - setAdvancedPivotEditorEnabled(!isAdvancedPivotEditorEnabled); - setAdvancedPivotEditorApplyButtonEnabled(false); - if (isAdvancedPivotEditorEnabled === false) { - setAdvancedEditorConfigLastApplied(advancedEditorConfig); - } - }; - // If switching to KQL after updating via editor - reset search - const toggleAdvancedSourceEditor = (reset = false) => { - if (reset === true) { - setSearchQuery(defaultSearch); - setSourceConfigUpdated(false); - } - if (isAdvancedSourceEditorEnabled === false) { - setAdvancedEditorSourceConfigLastApplied(advancedEditorSourceConfig); - } - - setAdvancedSourceEditorEnabled(!isAdvancedSourceEditorEnabled); - setAdvancedSourceEditorApplyButtonEnabled(false); - }; - - const advancedEditorHelpText = ( - - {i18n.translate('xpack.transform.stepDefineForm.advancedEditorHelpText', { - defaultMessage: - 'The advanced editor allows you to edit the pivot configuration of the transform.', - })}{' '} - - {i18n.translate('xpack.transform.stepDefineForm.advancedEditorHelpTextLink', { - defaultMessage: 'Learn more about available options.', - })} - - - ); - - const advancedSourceEditorHelpText = ( - - {i18n.translate('xpack.transform.stepDefineForm.advancedSourceEditorHelpText', { - defaultMessage: - 'The advanced editor allows you to edit the source query clause of the transform.', - })}{' '} - - {i18n.translate('xpack.transform.stepDefineForm.advancedEditorHelpTextLink', { - defaultMessage: 'Learn more about available options.', - })} - - - ); + stepDefineForm.pivotConfig.actions.setAggList(newAggList); - const valid = pivotGroupByArr.length > 0 && pivotAggsArr.length > 0; - - useEffect(() => { - const previewRequestUpdate = getPreviewRequestBody( - indexPattern.title, - pivotQuery, - pivotGroupByArr, - pivotAggsArr - ); - - const stringifiedSourceConfigUpdate = JSON.stringify( - previewRequestUpdate.source.query, - null, - 2 + stepDefineForm.advancedPivotEditor.actions.setAdvancedEditorConfigLastApplied( + advancedEditorConfig ); - setAdvancedEditorSourceConfig(stringifiedSourceConfigUpdate); - - onChange({ - aggList, - groupByList, - isAdvancedPivotEditorEnabled, - isAdvancedSourceEditorEnabled, - searchLanguage, - searchString, - searchQuery, - sourceConfigUpdated, - valid, - }); - // custom comparison - /* eslint-disable react-hooks/exhaustive-deps */ - }, [ - JSON.stringify(pivotAggsArr), - JSON.stringify(pivotGroupByArr), - isAdvancedPivotEditorEnabled, - isAdvancedSourceEditorEnabled, - searchLanguage, - searchString, - searchQuery, - valid, - /* eslint-enable react-hooks/exhaustive-deps */ - ]); + stepDefineForm.advancedPivotEditor.actions.setAdvancedPivotEditorApplyButtonEnabled(false); + }; - const indexPreviewProps = useIndexData(indexPattern, pivotQuery); - const pivotPreviewProps = usePivotData(indexPattern.title, pivotQuery, aggList, groupByList); + const { esQueryDsl } = useDocumentationLinks(); + const { esTransformPivot } = useDocumentationLinks(); - // TODO This should use the actual value of `indices.query.bool.max_clause_count` - const maxIndexFields = 1024; - const numIndexFields = indexPattern.fields.length; - const disabledQuery = numIndexFields > maxIndexFields; + const advancedEditorsSidebarWidth = '220px'; return ( - - -
- - {searchItems.savedSearch === undefined && ( - - - {indexPattern.title} - - {!disabledQuery && ( - - {!isAdvancedSourceEditorEnabled && ( - - setErrorMessage(undefined)} - input={ - - } - isOpen={ - errorMessage?.query === searchInput.query && - errorMessage?.message !== '' - } - > - - {i18n.translate( - 'xpack.transform.stepDefineForm.invalidKuerySyntaxErrorMessageQueryBar', - { - defaultMessage: 'Invalid query', - } - )} - {': '} - {errorMessage?.message.split('\n')[0]} - - - - )} - - )} - - )} - - {isAdvancedSourceEditorEnabled && ( - - - - { - setSearchString(undefined); - setAdvancedEditorSourceConfig(d); - - // Disable the "Apply"-Button if the config hasn't changed. - if (advancedEditorSourceConfigLastApplied === d) { - setAdvancedSourceEditorApplyButtonEnabled(false); - return; - } - - // Try to parse the string passed on from the editor. - // If parsing fails, the "Apply"-Button will be disabled - try { - JSON.parse(d); - setAdvancedSourceEditorApplyButtonEnabled(true); - } catch (e) { - setAdvancedSourceEditorApplyButtonEnabled(false); - } - }} - setOptions={{ - fontSize: '12px', - }} - theme="textmate" - aria-label={i18n.translate( - 'xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel', - { - defaultMessage: 'Advanced query editor', - } - )} - /> - - - - )} - {searchItems.savedSearch === undefined && ( - - - - { - if (isAdvancedSourceEditorEnabled && sourceConfigUpdated) { - setAdvancedSourceEditorSwitchModalVisible(true); - return; - } - - toggleAdvancedSourceEditor(); - }} - data-test-subj="transformAdvancedQueryEditorSwitch" - /> - {isAdvancedSourceEditorSwitchModalVisible && ( - setAdvancedSourceEditorSwitchModalVisible(false)} - onConfirm={() => { - setAdvancedSourceEditorSwitchModalVisible(false); - toggleAdvancedSourceEditor(true); - }} - type={'source'} +
+ + {searchItems.savedSearch === undefined && ( + + {indexPattern.title} + + )} + + <> + + + {/* Flex Column #1: Search Bar / Advanced Search Editor */} + {searchItems.savedSearch === undefined && ( + <> + {!disabledQuery && !isAdvancedSourceEditorEnabled && ( + )} + {isAdvancedSourceEditorEnabled && } + + )} + {searchItems?.savedSearch?.id !== undefined && ( + {searchItems.savedSearch.title} + )} + + + {/* Search options: Advanced Editor Switch / Copy to Clipboard / Advanced Editor Apply Button */} + + + + + + {searchItems.savedSearch === undefined && ( + + )} + + + + {(copy: () => void) => ( + + )} + + + {isAdvancedSourceEditorEnabled && ( - - {i18n.translate( - 'xpack.transform.stepDefineForm.advancedSourceEditorApplyButtonText', - { - defaultMessage: 'Apply changes', - } - )} - + + + + {i18n.translate( + 'xpack.transform.stepDefineForm.advancedSourceEditorHelpText', + { + defaultMessage: + 'The advanced editor allows you to edit the source query clause of the transform configuration.', + } + )}{' '} + + {i18n.translate( + 'xpack.transform.stepDefineForm.advancedEditorHelpTextLink', + { + defaultMessage: 'Learn more about available options.', + } + )} + + + + + {i18n.translate( + 'xpack.transform.stepDefineForm.advancedSourceEditorApplyButtonText', + { + defaultMessage: 'Apply changes', + } + )} + + )} - - )} - {searchItems.savedSearch !== undefined && searchItems.savedSearch.id !== undefined && ( - - {searchItems.savedSearch.title} - - )} - + + + + + + + + + + + {/* Flex Column #1: Pivot Config Form / Advanced Pivot Config Editor */} + {!isAdvancedPivotEditorEnabled && ( - - - - - - - - - - - - - - - + )} - {isAdvancedPivotEditorEnabled && ( - - - - { - setAdvancedEditorConfig(d); - - // Disable the "Apply"-Button if the config hasn't changed. - if (advancedEditorConfigLastApplied === d) { - setAdvancedPivotEditorApplyButtonEnabled(false); - return; - } - - // Try to parse the string passed on from the editor. - // If parsing fails, the "Apply"-Button will be disabled - try { - JSON.parse(convertToJson(d)); - setAdvancedPivotEditorApplyButtonEnabled(true); - } catch (e) { - setAdvancedPivotEditorApplyButtonEnabled(false); - } - }} - setOptions={{ - fontSize: '12px', - }} - theme="textmate" - aria-label={i18n.translate( - 'xpack.transform.stepDefineForm.advancedEditorAriaLabel', - { - defaultMessage: 'Advanced pivot editor', - } - )} - /> - - - + )} - - - - { - if ( - isAdvancedPivotEditorEnabled && - (isAdvancedPivotEditorApplyButtonEnabled || - advancedEditorConfig !== advancedEditorConfigLastApplied) - ) { - setAdvancedEditorSwitchModalVisible(true); - return; - } - - toggleAdvancedEditor(); - }} - data-test-subj="transformAdvancedPivotEditorSwitch" - /> - {isAdvancedEditorSwitchModalVisible && ( - setAdvancedEditorSwitchModalVisible(false)} - onConfirm={() => { - setAdvancedEditorSwitchModalVisible(false); - toggleAdvancedEditor(); - }} - type={'pivot'} - /> - )} - - {isAdvancedPivotEditorEnabled && ( + + + + + + + + + + + + {(copy: () => void) => ( + + )} + + + + + + {isAdvancedPivotEditorEnabled && ( + + + + <> + {i18n.translate('xpack.transform.stepDefineForm.advancedEditorHelpText', { + defaultMessage: + 'The advanced editor allows you to edit the pivot configuration of the transform.', + })}{' '} + + {i18n.translate( + 'xpack.transform.stepDefineForm.advancedEditorHelpTextLink', + { + defaultMessage: 'Learn more about available options.', + } + )} + + + + {i18n.translate( @@ -962,58 +373,15 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, } )} - )} - - - {!valid && ( - - - - {i18n.translate('xpack.transform.stepDefineForm.formHelp', { - defaultMessage: - 'Transforms are scalable and automated processes for pivoting. Choose at least one group-by and aggregation to get started.', - })} - - - )} - -
-
- - - - - - -
+ + )} + + + +
+ + + +
); }); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index f2e5d30b0601f..60bea6f20ae50 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -16,7 +16,7 @@ import { } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; -import { StepDefineExposedState } from './step_define_form'; +import { StepDefineExposedState } from './common'; import { StepDefineSummary } from './step_define_summary'; jest.mock('../../../../../shared_imports'); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index b9021f4ee5b11..414f6e37504da 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -8,14 +8,7 @@ import React, { Fragment, FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiText, -} from '@elastic/eui'; +import { EuiCodeBlock, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; import { dictionaryToArray } from '../../../../../../common/types/common'; @@ -35,7 +28,7 @@ import { SearchItems } from '../../../../hooks/use_search_items'; import { AggListSummary } from '../aggregation_list'; import { GroupByListSummary } from '../group_by_list'; -import { StepDefineExposedState } from './step_define_form'; +import { StepDefineExposedState } from './common'; interface Props { formState: StepDefineExposedState; @@ -65,85 +58,80 @@ export const StepDefineSummary: FC = ({ groupByList ); + const isModifiedQuery = + typeof searchString === 'undefined' && + !isDefaultQuery(pivotQuery) && + !isMatchAllQuery(pivotQuery); + return ( - - -
- - {searchItems.savedSearch === undefined && ( - - - {searchItems.indexPattern.title} - - {typeof searchString === 'string' && ( - - {searchString} - - )} - {typeof searchString === 'undefined' && - !isDefaultQuery(pivotQuery) && - !isMatchAllQuery(pivotQuery) && ( - - - {JSON.stringify(pivotQuery, null, 2)} - - - )} - +
+ + {searchItems.savedSearch === undefined && ( + + + {searchItems.indexPattern.title} + + {typeof searchString === 'string' && ( + + {searchString} + )} - - {searchItems.savedSearch !== undefined && searchItems.savedSearch.id !== undefined && ( + {isModifiedQuery && ( - {searchItems.savedSearch.title} + + {JSON.stringify(pivotQuery, null, 2)} + )} + + )} - - - - - - - - -
- - - + {searchItems.savedSearch !== undefined && searchItems.savedSearch.id !== undefined && ( + + {searchItems.savedSearch.title} + + )} + + + + + + + + + + = ({ toastNotifications={toastNotifications} /> - - +
+
); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/index.ts new file mode 100644 index 0000000000000..1aa00177cfbc8 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SwitchModal } from './switch_modal'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/switch_modal.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/switch_modal.tsx similarity index 100% rename from x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/switch_modal.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/switch_modal.tsx diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 0773ecbb1d8d3..3fcfd77ba54cd 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -155,8 +155,8 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) const stepsConfig = [ { - title: i18n.translate('xpack.transform.transformsWizard.stepDefineTitle', { - defaultMessage: 'Define pivot', + title: i18n.translate('xpack.transform.transformsWizard.stepConfigurationTitle', { + defaultMessage: 'Configuration', }), children: ( void; + config: TransformPivotConfig; +} + +export const EditTransformFlyout: FC = ({ closeFlyout, config }) => { + const { overlays } = useAppDependencies(); + const api = useApi(); + const toastNotifications = useToastNotifications(); + + const [state, dispatch] = useEditTransformFlyout(config); + + async function submitFormHandler() { + const requestConfig = applyFormFieldsToTransformConfig(config, state.formFields); + const transformId = config.id; + + try { + await api.updateTransform(transformId, requestConfig); + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.editTransformSuccessMessage', { + defaultMessage: 'Transform {transformId} updated.', + values: { transformId }, + }) + ); + closeFlyout(); + refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.transformList.editTransformGenericErrorMessage', { + defaultMessage: 'An error occurred calling the API endpoint to update transforms.', + }), + text: toMountPoint(), + }); + } + } + + const isUpdateButtonDisabled = !state.isFormValid || !state.isFormTouched; + + return ( + + + + +

+ {i18n.translate('xpack.transform.transformList.editFlyoutTitle', { + defaultMessage: 'Edit {transformId}', + values: { + transformId: config.id, + }, + })} +

+
+
+ }> + + + + + + + {i18n.translate('xpack.transform.transformList.editFlyoutCancelButtonText', { + defaultMessage: 'Cancel', + })} + + + + + {i18n.translate('xpack.transform.transformList.editFlyoutUpdateButtonText', { + defaultMessage: 'Update', + })} + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_callout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_callout.tsx new file mode 100644 index 0000000000000..06509360b762f --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_callout.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 React, { FC } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiTextColor, +} from '@elastic/eui'; + +import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; +export const EditTransformFlyoutCallout: FC = () => { + const { esTransformUpdate } = useDocumentationLinks(); + + return ( + + + + + + + + {i18n.translate('xpack.transform.transformList.editFlyoutCalloutText', { + defaultMessage: + 'This form allows you to update a transform. The list of properties that you can update is a subset of the list that you can define when you create a transform.', + })} + + + {i18n.translate('xpack.transform.transformList.editFlyoutCalloutDocs', { + defaultMessage: 'View docs', + })} + + + + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx new file mode 100644 index 0000000000000..3ea009a9bb6b4 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx @@ -0,0 +1,58 @@ +/* + * 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 { EuiForm } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { EditTransformFlyoutFormTextInput } from './edit_transform_flyout_form_text_input'; +import { UseEditTransformFlyoutReturnType } from './use_edit_transform_flyout'; + +interface EditTransformFlyoutFormProps { + editTransformFlyout: UseEditTransformFlyoutReturnType; +} + +export const EditTransformFlyoutForm: FC = ({ + editTransformFlyout: [state, dispatch], +}) => { + const formFields = state.formFields; + + return ( + + dispatch({ field: 'description', value })} + value={formFields.description.value} + /> + {/* + */} + dispatch({ field: 'frequency', value })} + placeholder="1m" + value={formFields.frequency.value} + /> + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_input.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_input.tsx new file mode 100644 index 0000000000000..e78a379bdad74 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_input.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; + +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; + +interface EditTransformFlyoutFormTextInputProps { + errorMessages: string[]; + helpText?: string; + label: string; + onChange: (value: string) => void; + placeholder?: string; + value: string; +} + +export const EditTransformFlyoutFormTextInput: FC = ({ + errorMessages, + helpText, + label, + onChange, + placeholder, + value, +}) => { + return ( + 0} + error={errorMessages} + > + 0} + value={value} + onChange={e => onChange(e.target.value)} + aria-label={label} + /> + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/index.ts new file mode 100644 index 0000000000000..685b1a554774e --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EditTransformFlyout } from './edit_transform_flyout'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts new file mode 100644 index 0000000000000..04a512cab5f1a --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts @@ -0,0 +1,168 @@ +/* + * 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 { TransformPivotConfig } from '../../../../common'; + +import { + applyFormFieldsToTransformConfig, + formReducerFactory, + frequencyValidator, + getDefaultState, +} from './use_edit_transform_flyout'; + +const getTransformConfigMock = (): TransformPivotConfig => ({ + id: 'the-transform-id', + source: { + index: ['the-transform-source-index'], + query: { + match_all: {}, + }, + }, + dest: { + index: 'the-transform-destination-index', + }, + pivot: { + group_by: { + airline: { + terms: { + field: 'airline', + }, + }, + }, + aggregations: { + 'responsetime.avg': { + avg: { + field: 'responsetime', + }, + }, + }, + }, + description: 'the-description', +}); + +const getDescriptionFieldMock = (value = '') => ({ + isOptional: true, + value, + errorMessages: [], + validator: 'string', +}); + +const getFrequencyFieldMock = (value = '') => ({ + isOptional: true, + value, + errorMessages: [], + validator: 'frequency', +}); + +describe('Transform: applyFormFieldsToTransformConfig()', () => { + test('should exclude unchanged form fields', () => { + const transformConfigMock = getTransformConfigMock(); + + const updateConfig = applyFormFieldsToTransformConfig(transformConfigMock, { + description: getDescriptionFieldMock(transformConfigMock.description), + frequency: getFrequencyFieldMock(), + }); + + // This case will return an empty object. In the actual UI, this case should not happen + // because the Update-Button will be disabled when no form field was changed. + expect(Object.keys(updateConfig)).toHaveLength(0); + expect(updateConfig.description).toBe(undefined); + expect(updateConfig.frequency).toBe(undefined); + }); + + test('should include previously nonexisting attributes', () => { + const transformConfigMock = getTransformConfigMock(); + delete transformConfigMock.description; + delete transformConfigMock.frequency; + + const updateConfig = applyFormFieldsToTransformConfig(transformConfigMock, { + description: getDescriptionFieldMock('the-new-description'), + frequency: getFrequencyFieldMock(undefined), + }); + + expect(Object.keys(updateConfig)).toHaveLength(1); + expect(updateConfig.description).toBe('the-new-description'); + expect(updateConfig.frequency).toBe(undefined); + }); + + test('should only include changed form fields', () => { + const transformConfigMock = getTransformConfigMock(); + const updateConfig = applyFormFieldsToTransformConfig(transformConfigMock, { + description: getDescriptionFieldMock('the-updated-description'), + frequency: getFrequencyFieldMock(), + }); + + expect(Object.keys(updateConfig)).toHaveLength(1); + expect(updateConfig.description).toBe('the-updated-description'); + expect(updateConfig.frequency).toBe(undefined); + }); +}); + +describe('Transform: formReducerFactory()', () => { + test('field updates should trigger form validation', () => { + const transformConfigMock = getTransformConfigMock(); + const reducer = formReducerFactory(transformConfigMock); + + const state1 = reducer(getDefaultState(transformConfigMock), { + field: 'description', + value: 'the-updated-description', + }); + + expect(state1.isFormTouched).toBe(true); + expect(state1.isFormValid).toBe(true); + + const state2 = reducer(state1, { + field: 'description', + value: transformConfigMock.description as string, + }); + + expect(state2.isFormTouched).toBe(false); + expect(state2.isFormValid).toBe(true); + + const state3 = reducer(state2, { + field: 'frequency', + value: 'the-invalid-value', + }); + + expect(state3.isFormTouched).toBe(true); + expect(state3.isFormValid).toBe(false); + expect(state3.formFields.frequency.errorMessages).toStrictEqual([ + 'The frequency value is not valid.', + ]); + }); +}); + +describe('Transform: frequencyValidator()', () => { + test('it should only allow values between 1s and 1h', () => { + // frequencyValidator() returns an array of error messages so + // an array with a length of 0 means a successful validation. + + // invalid + expect(frequencyValidator(0)).toHaveLength(1); + expect(frequencyValidator('0')).toHaveLength(1); + expect(frequencyValidator('0s')).toHaveLength(1); + expect(frequencyValidator(1)).toHaveLength(1); + expect(frequencyValidator('1')).toHaveLength(1); + expect(frequencyValidator('1ms')).toHaveLength(1); + expect(frequencyValidator('1d')).toHaveLength(1); + expect(frequencyValidator('60s')).toHaveLength(1); + expect(frequencyValidator('60m')).toHaveLength(1); + expect(frequencyValidator('60h')).toHaveLength(1); + expect(frequencyValidator('2h')).toHaveLength(1); + expect(frequencyValidator('h2')).toHaveLength(1); + expect(frequencyValidator('2h2')).toHaveLength(1); + expect(frequencyValidator('h2h')).toHaveLength(1); + + // valid + expect(frequencyValidator('1s')).toHaveLength(0); + expect(frequencyValidator('1m')).toHaveLength(0); + expect(frequencyValidator('1h')).toHaveLength(0); + expect(frequencyValidator('10s')).toHaveLength(0); + expect(frequencyValidator('10m')).toHaveLength(0); + expect(frequencyValidator('59s')).toHaveLength(0); + expect(frequencyValidator('59m')).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts new file mode 100644 index 0000000000000..8c4af7ac252f7 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEqual } from 'lodash'; +import { useReducer } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { TransformPivotConfig } from '../../../../common'; + +const stringNotValidErrorMessage = i18n.translate( + 'xpack.transform.transformList.editFlyoutFormStringNotValidErrorMessage', + { + defaultMessage: 'Value needs to be of type string.', + } +); + +type Validator = (arg: any) => string[]; + +// The way the current form is set up, +// this validator is just a sanity check, +// it should never trigger an error. +const stringValidator: Validator = arg => + typeof arg === 'string' ? [] : [stringNotValidErrorMessage]; + +const frequencyNotValidErrorMessage = i18n.translate( + 'xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage', + { + defaultMessage: 'The frequency value is not valid.', + } +); + +// Only allow frequencies in the form of 1s/1h etc. +export const frequencyValidator: Validator = arg => { + if (typeof arg !== 'string' || arg === null) { + return [stringNotValidErrorMessage]; + } + + // split string by groups of numbers and letters + const regexStr = arg.match(/[a-z]+|[^a-z]+/gi); + + return ( + // only valid if one group of numbers and one group of letters + regexStr !== null && + regexStr.length === 2 && + // only valid if time unit is one of s/m/h + ['s', 'm', 'h'].includes(regexStr[1]) && + // only valid if number is between 1 and 59 + parseInt(regexStr[0], 10) > 0 && + parseInt(regexStr[0], 10) < 60 && + // if time unit is 'h' then number must not be higher than 1 + !(parseInt(regexStr[0], 10) > 1 && regexStr[1] === 'h') + ? [] + : [frequencyNotValidErrorMessage] + ); +}; + +interface Validate { + [key: string]: Validator; +} + +const validate: Validate = { + string: stringValidator, + frequency: frequencyValidator, +}; + +interface Field { + errorMessages: string[]; + isOptional: boolean; + validator: keyof typeof validate; + value: string; +} + +const defaultField: Field = { + errorMessages: [], + isOptional: true, + validator: 'string', + value: '', +}; + +interface EditTransformFlyoutFieldsState { + [key: string]: Field; + description: Field; + frequency: Field; +} + +export interface EditTransformFlyoutState { + formFields: EditTransformFlyoutFieldsState; + isFormTouched: boolean; + isFormValid: boolean; +} + +// This is not a redux type action, +// since for now we only have one action type. +interface Action { + field: keyof EditTransformFlyoutFieldsState; + value: string; +} + +// Some attributes can have a value of `null` to trigger +// a reset to the default value. +interface UpdateTransformPivotConfig { + description: string; + frequency: string; +} + +// Takes in the form configuration and returns a +// request object suitable to be sent to the +// transform update API endpoint. +export const applyFormFieldsToTransformConfig = ( + config: TransformPivotConfig, + { description, frequency }: EditTransformFlyoutFieldsState +): Partial => { + const updateConfig: Partial = {}; + + // set the values only if they changed from the default + // and actually differ from the previous value. + if ( + !(config.frequency === undefined && frequency.value === '') && + config.frequency !== frequency.value + ) { + updateConfig.frequency = frequency.value; + } + + if ( + !(config.description === undefined && description.value === '') && + config.description !== description.value + ) { + updateConfig.description = description.value; + } + + return updateConfig; +}; + +// Takes in a transform configuration and returns +// the default state to populate the form. +export const getDefaultState = (config: TransformPivotConfig): EditTransformFlyoutState => ({ + formFields: { + description: { ...defaultField, value: config?.description ?? '' }, + frequency: { ...defaultField, value: config?.frequency ?? '', validator: 'frequency' }, + }, + isFormTouched: false, + isFormValid: true, +}); + +// Checks each form field for error messages to return +// if the overall form is valid or not. +const isFormValid = (fieldsState: EditTransformFlyoutFieldsState) => + Object.keys(fieldsState).reduce((p, c) => p && fieldsState[c].errorMessages.length === 0, true); + +// Updates a form field with its new value, +// runs validation and populates +// `errorMessages` if any errors occur. +const formFieldReducer = (state: Field, value: string): Field => { + return { + ...state, + errorMessages: state.isOptional && value.length === 0 ? [] : validate[state.validator](value), + value, + }; +}; + +// Main form reducer triggers +// - `formFieldReducer` to update the actions field +// - compares the most recent state against the original one to update `isFormTouched` +// - sets `isFormValid` to have a flag if any of the form fields contains an error. +export const formReducerFactory = (config: TransformPivotConfig) => { + const defaultState = getDefaultState(config); + return (state: EditTransformFlyoutState, { field, value }: Action): EditTransformFlyoutState => { + const formFields = { + ...state.formFields, + [field]: formFieldReducer(state.formFields[field], value), + }; + + return { + ...state, + formFields, + isFormTouched: !isEqual(defaultState.formFields, formFields), + isFormValid: isFormValid(formFields), + }; + }; +}; + +export const useEditTransformFlyout = (config: TransformPivotConfig) => { + return useReducer(formReducerFactory(config), getDefaultState(config)); +}; + +export type UseEditTransformFlyoutReturnType = ReturnType; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_edit.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_edit.tsx new file mode 100644 index 0000000000000..1aafbac1e1bcb --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_edit.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useState, FC } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; + +import { TransformPivotConfig } from '../../../../common'; +import { + createCapabilityFailureMessage, + AuthorizationContext, +} from '../../../../lib/authorization'; + +import { EditTransformFlyout } from '../edit_transform_flyout'; + +interface EditActionProps { + config: TransformPivotConfig; +} + +export const EditAction: FC = ({ config }) => { + const { canCreateTransform } = useContext(AuthorizationContext).capabilities; + + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const closeFlyout = () => setIsFlyoutVisible(false); + const showFlyout = () => setIsFlyoutVisible(true); + + const buttonEditText = i18n.translate('xpack.transform.transformList.editActionName', { + defaultMessage: 'Edit', + }); + + const editButton = ( + + {buttonEditText} + + ); + + if (!canCreateTransform) { + const content = createCapabilityFailureMessage('canStartStopTransform'); + + return ( + + {editButton} + + ); + } + + return ( + <> + {editButton} + {isFlyoutVisible && } + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx index e8ac2fa057ad8..aac1d8b5a3f9c 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx @@ -12,10 +12,11 @@ describe('Transform: Transform List Actions', () => { test('getActions()', () => { const actions = getActions({ forceDisable: false }); - expect(actions).toHaveLength(3); + expect(actions).toHaveLength(4); expect(actions[0].isPrimary).toBeTruthy(); expect(typeof actions[0].render).toBe('function'); expect(typeof actions[1].render).toBe('function'); expect(typeof actions[2].render).toBe('function'); + expect(typeof actions[3].render).toBe('function'); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx index 6a55b419e74a9..820b9e0e0d062 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx @@ -11,9 +11,10 @@ import { TRANSFORM_STATE } from '../../../../../../common'; import { TransformListRow } from '../../../../common'; import { CloneAction } from './action_clone'; +import { DeleteAction } from './action_delete'; +import { EditAction } from './action_edit'; import { StartAction } from './action_start'; import { StopAction } from './action_stop'; -import { DeleteAction } from './action_delete'; export const getActions = ({ forceDisable }: { forceDisable: boolean }) => { return [ @@ -26,6 +27,11 @@ export const getActions = ({ forceDisable }: { forceDisable: boolean }) => { return ; }, }, + { + render: (item: TransformListRow) => { + return ; + }, + }, { render: (item: TransformListRow) => { return ; diff --git a/x-pack/plugins/transform/server/client/elasticsearch_transform.ts b/x-pack/plugins/transform/server/client/elasticsearch_transform.ts index fab9ab7f3a995..91c00f5eb5df2 100644 --- a/x-pack/plugins/transform/server/client/elasticsearch_transform.ts +++ b/x-pack/plugins/transform/server/client/elasticsearch_transform.ts @@ -65,6 +65,21 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'PUT', }); + transform.updateTransform = ca({ + urls: [ + { + fmt: '/_transform/<%=transformId%>/_update', + req: { + transformId: { + type: 'string', + }, + }, + }, + ], + needBody: true, + method: 'POST', + }); + transform.deleteTransform = ca({ urls: [ { diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index bf201323a3c2f..54fbce2aa3c7f 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -146,6 +146,29 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { return res.ok({ body: response }); }) ); + router.post( + { + path: addBasePath('transforms/{transformId}/_update'), + validate: { + ...schemaTransformId, + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + + try { + return res.ok({ + body: await ctx.transform!.dataClient.callAsCurrentUser('transform.updateTransform', { + body: req.body, + transformId, + }), + }); + } catch (e) { + return res.customError(wrapError(e)); + } + }) + ); router.post( { path: addBasePath('delete_transforms'), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8b05f973c74eb..b5b6ae7c64d32 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3969,7 +3969,6 @@ "visualize.listing.table.titleColumnName": "タイトル", "visualize.listing.table.typeColumnName": "タイプ", "visualize.pageHeading": "{chartName} {chartType} ビジュアライゼーション", - "visualize.saveDialog.saveAndAddToDashboardButtonLabel": "保存してダッシュボードに追加", "visualize.topNavMenu.openInspectorButtonAriaLabel": "ビジュアライゼーションのインスペクターを開く", "visualize.topNavMenu.openInspectorDisabledButtonTooltip": "このビジュアライゼーションはインスペクターをサポートしていません。", "visualize.topNavMenu.saveVisualization.failureNotificationText": "「{visTitle}」の保存中にエラーが発生しました", @@ -8219,18 +8218,9 @@ "xpack.ingestManager.agentDetails.statusLabel": "ステータス", "xpack.ingestManager.agentDetails.unexceptedErrorTitle": "エージェントを読み込む間にエラーが発生しました", "xpack.ingestManager.agentDetails.userProvidedMetadataSectionSubtitle": "ユーザー提供メタデータ", - "xpack.ingestManager.agentEnrollment.apiKeySelectionDescription": "ご希望のエージェント構成とプラットフォームをすばやく選択できます。次いで、以下の手順に従ってエージェントをセットアップして登録します。", "xpack.ingestManager.agentEnrollment.cancelButtonLabel": "キャンセル", "xpack.ingestManager.agentEnrollment.continueButtonLabel": "続行", "xpack.ingestManager.agentEnrollment.flyoutTitle": "新しいエージェントを登録", - "xpack.ingestManager.agentEnrollment.installManuallyTitle": "手動でインストール", - "xpack.ingestManager.agentEnrollment.newAgentsMessage": "{count, plural, one {# 新規エージェント} other {# 新規エージェント}}。", - "xpack.ingestManager.agentEnrollment.quickInstallTitle": "簡易インストール", - "xpack.ingestManager.agentEnrollment.selectAgentConfig": "エージェント構成", - "xpack.ingestManager.agentEnrollment.selectAPIKeyTitle": "登録 API キー", - "xpack.ingestManager.agentEnrollment.stepSetupAgents": "Beats エージェントのセットアップ", - "xpack.ingestManager.agentEnrollment.stepTestAgents": "エージェントのテスト", - "xpack.ingestManager.agentEnrollment.testAgentLoadingMessage": "新しいエージェントの登録を待っています", "xpack.ingestManager.agentEventsList.messageColumnTitle": "メッセージ", "xpack.ingestManager.agentEventsList.refreshButton": "更新", "xpack.ingestManager.agentEventsList.subtypeColumnTitle": "サブタイプ", @@ -8340,9 +8330,6 @@ "xpack.ingestManager.deleteDatasource.successSingleNotificationTitle": "データソース「{id}」を削除しました", "xpack.ingestManager.disabledSecurityDescription": "Elastic Fleet を使用するには、Kibana と Elasticsearch でセキュリティを有効にする必要があります。", "xpack.ingestManager.disabledSecurityTitle": "セキュリティが有効ではありません", - "xpack.ingestManager.enrollmentApiKeyForm.namePlaceholder": "名前を選択", - "xpack.ingestManager.enrollmentApiKeyList.createNewButton": "新規キーを作成", - "xpack.ingestManager.enrollmentApiKeyList.useExistingsButton": "既存のキーを使用", "xpack.ingestManager.epm.addDatasourceButtonText": "データソースを作成", "xpack.ingestManager.epm.pageSubtitle": "人気のアプリやサービスのパッケージを参照する", "xpack.ingestManager.epm.pageTitle": "Elastic Package Manager", @@ -8371,13 +8358,10 @@ "xpack.ingestManager.unenrollAgents.confirmModal.loadingButtonLabel": "読み込み中...", "xpack.ingestManager.unenrollAgents.fatalErrorNotificationTitle": "エージェントの登録解除エラー", "xpack.ingestManager.unenrollAgents.successSingleNotificationTitle": "エージェント「{id}」の登録を解除しました", - "xpack.ingestManager.yamlConfig.instructionDescription": "この構成でエージェントを登録するには、ホストで次のコマンドをコピーして実行します。", - "xpack.ingestManager.yamlConfig.instructionTittle": "フリートに登録", "xpack.lens.app.docLoadingError": "保存されたドキュメントの保存中にエラーが発生", "xpack.lens.app.docSavingError": "ドキュメントの保存中にエラーが発生", "xpack.lens.app.indexPatternLoadingError": "インデックスパターンの読み込み中にエラーが発生", "xpack.lens.app.save": "保存", - "xpack.lens.app.saveAddToDashboard": "保存してダッシュボードに追加", "xpack.lens.app.saveModalType": "レンズビジュアライゼーション", "xpack.lens.app404": "404 Not Found", "xpack.lens.breadcrumbsCreate": "作成", @@ -9781,7 +9765,6 @@ "xpack.ml.jobMessages.timeLabel": "時間", "xpack.ml.jobsBreadcrumbs.advancedConfigurationLabel": "高度な構成", "xpack.ml.jobsBreadcrumbs.categorizationLabel": "カテゴリー分け", - "xpack.ml.jobsBreadcrumbs.createJobLabel": "ジョブを作成", "xpack.ml.jobsBreadcrumbs.jobWizardLabel": "ジョブを作成", "xpack.ml.jobsBreadcrumbs.multiMetricLabel": "マルチメトリック", "xpack.ml.jobsBreadcrumbs.populationLabel": "集団", @@ -10267,7 +10250,6 @@ "xpack.ml.newJob.wizard.jobType.singleMetricTitle": "シングルメトリック", "xpack.ml.newJob.wizard.jobType.useSuppliedConfigurationDescription": "データのフィールドが既知のカテゴリーと一致することが認識されました。選択して一連の機械学習ジョブと関連ダッシュボードを作成します。", "xpack.ml.newJob.wizard.jobType.useSuppliedConfigurationTitle": "提供された構成を使用", - "xpack.ml.newJob.wizard.jobType.useWizardDescription": "ウィザードの 1 つを使用し、データの異常を検知する機械学習ジョブを作成します。", "xpack.ml.newJob.wizard.jobType.useWizardTitle": "ウィザードを使用", "xpack.ml.newJob.wizard.jsonFlyout.closeButton": "閉じる", "xpack.ml.newJob.wizard.jsonFlyout.datafeed.title": "データフィード構成 JSON", @@ -12898,7 +12880,6 @@ "xpack.security.management.users.editUser.cancelButtonLabel": "キャンセル", "xpack.security.management.users.editUser.changePasswordButtonLabel": "パスワードを変更", "xpack.security.management.users.editUser.changePasswordExtraStepTitle": "追加ステップが必要です", - "xpack.security.management.users.editUser.changePasswordUpdateKibanaTitle": "Kibana ユーザーのパスワードを変更後、{kibana} ファイルを更新し Kibana を再起動する必要があります。", "xpack.security.management.users.editUser.changingUserNameAfterCreationDescription": "ユーザー名は作成後変更できません。", "xpack.security.management.users.editUser.confirmPasswordFormRowLabel": "パスワードの確認", "xpack.security.management.users.editUser.createUserButtonLabel": "ユーザーを作成", @@ -13142,7 +13123,6 @@ "xpack.siem.case.caseView.actionLabel.pushedNewIncident": "新しいインシデントとしてプッシュしました", "xpack.siem.case.caseView.actionLabel.removedField": "削除しました", "xpack.siem.case.caseView.actionLabel.updateIncident": "インシデントを更新しました", - "xpack.siem.case.caseView.alreadyPushedToService": "既に ServiceNow インシデントにプッシュされました", "xpack.siem.case.caseView.backLabel": "ケースに戻る", "xpack.siem.case.caseView.breadcrumb": "作成", "xpack.siem.case.caseView.cancel": "キャンセル", @@ -13180,7 +13160,6 @@ "xpack.siem.case.caseView.pageBadgeLabel": "ベータ", "xpack.siem.case.caseView.pageBadgeTooltip": "ケースワークフローはまだベータです。Kibana repo で問題や不具合を報告して製品の改善にご協力ください。", "xpack.siem.case.caseView.particpantsLabel": "参加者", - "xpack.siem.case.caseView.pushAsServicenowIncident": "ServiceNow インシデントとしてプッシュ", "xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedDescription": "終了したケースは外部システムに送信できません。外部システムでケースを開始または更新したい場合にはケースを再開します。", "xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle": "ケースを再開する", "xpack.siem.case.caseView.pushToServiceDisableByConfigDescription": "kibana.yml ファイルは、特定のコネクターのみを許可するように構成されています。外部システムでケースを開けるようにするには、xpack.actions.enabled Actiontypes 設定に .servicenow を追加します。詳細は {link} をご覧ください。", @@ -13192,11 +13171,9 @@ "xpack.siem.case.caseView.reopenCase": "ケースを再開", "xpack.siem.case.caseView.reopenedCase": "ケースを再開する", "xpack.siem.case.caseView.reporterLabel": "報告者", - "xpack.siem.case.caseView.requiredUpdateToService": "ServiceNow インシデントを更新する必要があります", "xpack.siem.case.caseView.statusLabel": "ステータス", "xpack.siem.case.caseView.tags": "タグ", "xpack.siem.case.caseView.to": "に", - "xpack.siem.case.caseView.updatePushAsServicenowIncident": "ServiceNow インシデントを更新", "xpack.siem.case.configureCases.addNewConnector": "新しいコネクターオプションを追加", "xpack.siem.case.configureCases.cancelButton": "キャンセル", "xpack.siem.case.configureCases.caseClosureOptionsClosedIncident": "新しいインシデントがサードパーティで閉じたときに SIEM ケースを自動的に閉じる", @@ -13209,10 +13186,6 @@ "xpack.siem.case.configureCases.fieldMappingEditAppend": "末尾に追加", "xpack.siem.case.configureCases.fieldMappingEditNothing": "何もしない", "xpack.siem.case.configureCases.fieldMappingEditOverwrite": "上書き", - "xpack.siem.case.configureCases.fieldMappingFieldComments": "コメント", - "xpack.siem.case.configureCases.fieldMappingFieldDescription": "説明", - "xpack.siem.case.configureCases.fieldMappingFieldNotMapped": "マップされません", - "xpack.siem.case.configureCases.fieldMappingFieldShortDescription": "短い説明", "xpack.siem.case.configureCases.fieldMappingFirstCol": "SIEM ケースフィールド", "xpack.siem.case.configureCases.fieldMappingSecondCol": "サードパーティインシデントフィールド", "xpack.siem.case.configureCases.fieldMappingThirdCol": "編集時と更新時", @@ -15470,14 +15443,12 @@ "xpack.transform.stepDefineForm.advancedSourceEditorApplyButtonText": "変更を適用", "xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel": "クエリの詳細エディター", "xpack.transform.stepDefineForm.advancedSourceEditorHelpText": "高度なエディターでは、変換のソースクエリ句を編集できます。", - "xpack.transform.stepDefineForm.advancedSourceEditorLabel": "ソースクエリ句", "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalBodyText": "クエリバーに戻すと、編集内容が失われます。", "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalConfirmButtonText": "クエリバーに切り替え", "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalTitle": "編集内容は失われます", "xpack.transform.stepDefineForm.aggExistsErrorMessage": "「{aggName}」という名前の集約構成は既に存在します。", "xpack.transform.stepDefineForm.aggregationsLabel": "アグリゲーション(集計)", "xpack.transform.stepDefineForm.aggregationsPlaceholder": "集約を追加…", - "xpack.transform.stepDefineForm.formHelp": "変換は、ピボット用のスケーラブルで自動化されたプロセスです。開始するにはグループ分けの条件と集約を少なくとも 1 つ選んでください。", "xpack.transform.stepDefineForm.groupByExistsErrorMessage": "「{aggName}」という名前のグループ分け構成は既に存在します。", "xpack.transform.stepDefineForm.groupByLabel": "グループ分けの条件", "xpack.transform.stepDefineForm.groupByPlaceholder": "グループ分けの条件フィールドを追加…", @@ -15486,8 +15457,6 @@ "xpack.transform.stepDefineForm.nestedAggListConflictErrorMessage": "「{aggListName}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", "xpack.transform.stepDefineForm.nestedConflictErrorMessage": "「{aggNameCheck}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", "xpack.transform.stepDefineForm.nestedGroupByListConflictErrorMessage": "「{groupByListName}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", - "xpack.transform.stepDefineForm.queryHelpText": "クエリ文字列でソースデータをフィルタリングしてください (オプション)。", - "xpack.transform.stepDefineForm.queryLabel": "クエリ", "xpack.transform.stepDefineForm.queryPlaceholderKql": "例: {example}", "xpack.transform.stepDefineForm.queryPlaceholderLucene": "例: {example}", "xpack.transform.stepDefineForm.savedSearchLabel": "保存検索", @@ -15577,7 +15546,6 @@ "xpack.transform.transformsWizard.cloneTransformTitle": "クローン変換", "xpack.transform.transformsWizard.createTransformTitle": "変換の作成", "xpack.transform.transformsWizard.stepCreateTitle": "作成", - "xpack.transform.transformsWizard.stepDefineTitle": "ピボットの定義", "xpack.transform.transformsWizard.stepDetailsTitle": "ジョブの詳細", "xpack.transform.transformsWizard.transformDocsLinkText": "変換ドキュメント", "xpack.transform.wizard.nextStepButton": "次へ", @@ -15721,7 +15689,6 @@ "xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionName": "削除", "xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actionTypeTitle": "タイプ", "xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.nameTitle": "名前", - "xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.referencedByCountTitle": "アクション", "xpack.triggersActionsUI.sections.actionsConnectorsList.filters.actionTypeIdName": "タイプ", "xpack.triggersActionsUI.sections.actionsConnectorsList.multipleTitle": "コネクター", "xpack.triggersActionsUI.sections.actionsConnectorsList.noPermissionToCreateTitle": "コネクターを作成するパーミッションがありません。", @@ -15776,10 +15743,6 @@ "xpack.triggersActionsUI.sections.alertAdd.thresholdPreviewChart.noDataTitle": "このクエリに一致するデータはありません", "xpack.triggersActionsUI.sections.alertAdd.unableToLoadVisualizationMessage": "ビジュアライゼーションを読み込めません", "xpack.triggersActionsUI.sections.alertDetails.alertInstances.disabledAlert": "このアラートは無効になっていて再表示できません。[↑ を有効にする]を切り替えてアクティブにします。", - "xpack.triggersActionsUI.sections.alertDetails.alertInstances.mutedAlert": "ミュート", - "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.actions.mute": "ミュート", - "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.actions.unmute": "ミュート解除", - "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.actions": "アクション", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.duration": "期間", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.instance": "インスタンス", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.start": "開始", @@ -15787,7 +15750,6 @@ "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.active": "アクティブ", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.inactive": "非アクティブ", "xpack.triggersActionsUI.sections.alertDetails.betaBadgeTooltipContent": "{pluginName} はベータ段階で、変更される可能性があります。デザインとコードはオフィシャル GA 機能よりも完成度が低く、現状のまま保証なしで提供されています。ベータ機能にはオフィシャル GA 機能の SLA が適用されません。", - "xpack.triggersActionsUI.sections.alertDetails.collapsedItemActons.enableTitle": "有効にする", "xpack.triggersActionsUI.sections.alertDetails.collapsedItemActons.muteTitle": "ミュート", "xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertMessage": "アラートを読み込めません: {message}", "xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertStateMessage": "アラートステートを読み込めません: {message}", @@ -15994,7 +15956,6 @@ "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteTitle": "クラスターがアップグレードされました", "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingDescription": "1 つまたは複数の Elasticsearch ノードに、 Kibana よりも新しいバージョンの Elasticsearch があります。すべてのノードがアップグレードされた後で Kibana をアップグレードしてください。", "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingTitle": "クラスターをアップグレード中です", - "xpack.uptime.alerts.locationSelectionItem.ariaLabel": "「{location}」の場所選択項目", "xpack.uptime.alerts.message.emptyTitle": "停止状況監視 ID を受信していません。", "xpack.uptime.alerts.message.fullListOverflow": "... とその他 {overflowCount} {pluralizedMonitor}", "xpack.uptime.alerts.message.multipleTitle": "停止状況監視: ", @@ -16002,9 +15963,6 @@ "xpack.uptime.alerts.message.singularTitle": "停止状況監視: ", "xpack.uptime.alerts.monitorStatus": "稼働状況監視ステータス", "xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel": "監視状態アラートのフィルター基準を許可するインプット", - "xpack.uptime.alerts.monitorStatus.locationSelection": "場所 {location} を選択します", - "xpack.uptime.alerts.monitorStatus.locationSelectionSwitch.ariaLabel": "アラートをトリガーする場所を選択します", - "xpack.uptime.alerts.monitorStatus.locationsSelectionExpression.ariaLabel": "ポップオーバーを開いてアラートをトリガーする場所を選択する", "xpack.uptime.alerts.monitorStatus.numTimesExpression.ariaLabel": "ダウンカウントインプットのポップオーバーを開く", "xpack.uptime.alerts.monitorStatus.numTimesField.ariaLabel": "アラートのトリガーに必要な停止回数を入力します", "xpack.uptime.alerts.monitorStatus.timerangeOption.days": "日", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 90794f7512fe6..1370295d1412f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3970,7 +3970,6 @@ "visualize.listing.table.titleColumnName": "标题", "visualize.listing.table.typeColumnName": "类型", "visualize.pageHeading": "{chartName} {chartType}可视化", - "visualize.saveDialog.saveAndAddToDashboardButtonLabel": "保存并添加到仪表板", "visualize.topNavMenu.openInspectorButtonAriaLabel": "打开检查器查看可视化", "visualize.topNavMenu.openInspectorDisabledButtonTooltip": "此可视化不支持任何检查器。", "visualize.topNavMenu.saveVisualization.failureNotificationText": "保存 “{visTitle}” 时出错", @@ -8225,18 +8224,9 @@ "xpack.ingestManager.agentDetails.statusLabel": "状态", "xpack.ingestManager.agentDetails.unexceptedErrorTitle": "加载代理时发生错误", "xpack.ingestManager.agentDetails.userProvidedMetadataSectionSubtitle": "用户提供的元数据", - "xpack.ingestManager.agentEnrollment.apiKeySelectionDescription": "快速选择所需的代理配置和平台。然后,根据下面的说明设置和注册代理。", "xpack.ingestManager.agentEnrollment.cancelButtonLabel": "取消", "xpack.ingestManager.agentEnrollment.continueButtonLabel": "继续", "xpack.ingestManager.agentEnrollment.flyoutTitle": "注册新代理", - "xpack.ingestManager.agentEnrollment.installManuallyTitle": "手动安装", - "xpack.ingestManager.agentEnrollment.newAgentsMessage": "{count, plural, one {# 个新代理} other {# 个新代理}}.", - "xpack.ingestManager.agentEnrollment.quickInstallTitle": "快速安装", - "xpack.ingestManager.agentEnrollment.selectAgentConfig": "代理配置", - "xpack.ingestManager.agentEnrollment.selectAPIKeyTitle": "注册 API 密钥", - "xpack.ingestManager.agentEnrollment.stepSetupAgents": "设置 Beats 代理", - "xpack.ingestManager.agentEnrollment.stepTestAgents": "测试代理", - "xpack.ingestManager.agentEnrollment.testAgentLoadingMessage": "正在等候新代理注册", "xpack.ingestManager.agentEventsList.messageColumnTitle": "消息", "xpack.ingestManager.agentEventsList.refreshButton": "刷新", "xpack.ingestManager.agentEventsList.subtypeColumnTitle": "子类型", @@ -8346,9 +8336,6 @@ "xpack.ingestManager.deleteDatasource.successSingleNotificationTitle": "已删除数据源“{id}”", "xpack.ingestManager.disabledSecurityDescription": "必须在 Kibana 和 Elasticsearch 启用安全性,才能使用 Elastic Fleet。", "xpack.ingestManager.disabledSecurityTitle": "安全性未启用", - "xpack.ingestManager.enrollmentApiKeyForm.namePlaceholder": "选择名称", - "xpack.ingestManager.enrollmentApiKeyList.createNewButton": "创建新密钥", - "xpack.ingestManager.enrollmentApiKeyList.useExistingsButton": "使用现有密钥", "xpack.ingestManager.epm.addDatasourceButtonText": "创建数据源", "xpack.ingestManager.epm.pageSubtitle": "浏览热门应用和服务的软件。", "xpack.ingestManager.epm.pageTitle": "Elastic Package Manager", @@ -8377,13 +8364,10 @@ "xpack.ingestManager.unenrollAgents.confirmModal.loadingButtonLabel": "正在加载……", "xpack.ingestManager.unenrollAgents.fatalErrorNotificationTitle": "取消注册代理时出错", "xpack.ingestManager.unenrollAgents.successSingleNotificationTitle": "已取消注册代理“{id}”", - "xpack.ingestManager.yamlConfig.instructionDescription": "要将代理注册到此配置,请在您的主机上复制并运行以下命令。", - "xpack.ingestManager.yamlConfig.instructionTittle": "注册到 fleet", "xpack.lens.app.docLoadingError": "加载已保存文档时出错", "xpack.lens.app.docSavingError": "保存文档时出错", "xpack.lens.app.indexPatternLoadingError": "加载索引模式时出错", "xpack.lens.app.save": "保存", - "xpack.lens.app.saveAddToDashboard": "保存并添加到仪表板", "xpack.lens.app.saveModalType": "Lens 可视化", "xpack.lens.app404": "404 找不到", "xpack.lens.breadcrumbsCreate": "创建", @@ -9787,7 +9771,6 @@ "xpack.ml.jobMessages.timeLabel": "时间", "xpack.ml.jobsBreadcrumbs.advancedConfigurationLabel": "高级配置", "xpack.ml.jobsBreadcrumbs.categorizationLabel": "归类", - "xpack.ml.jobsBreadcrumbs.createJobLabel": "创建作业", "xpack.ml.jobsBreadcrumbs.jobWizardLabel": "创建作业", "xpack.ml.jobsBreadcrumbs.multiMetricLabel": "多指标", "xpack.ml.jobsBreadcrumbs.populationLabel": "填充", @@ -10273,7 +10256,6 @@ "xpack.ml.newJob.wizard.jobType.singleMetricTitle": "单一指标", "xpack.ml.newJob.wizard.jobType.useSuppliedConfigurationDescription": "数据中的字段已被识别为匹配已知配置。选择并创建一组 Machine Learning 作业和关联的仪表板。", "xpack.ml.newJob.wizard.jobType.useSuppliedConfigurationTitle": "使用提供的配置", - "xpack.ml.newJob.wizard.jobType.useWizardDescription": "使用其中一个向导创建 Machine Learning 作业,以查找数据中的异常。", "xpack.ml.newJob.wizard.jobType.useWizardTitle": "使用向导", "xpack.ml.newJob.wizard.jsonFlyout.closeButton": "关闭", "xpack.ml.newJob.wizard.jsonFlyout.datafeed.title": "数据馈送配置 JSON", @@ -12905,7 +12887,6 @@ "xpack.security.management.users.editUser.cancelButtonLabel": "取消", "xpack.security.management.users.editUser.changePasswordButtonLabel": "更改密码", "xpack.security.management.users.editUser.changePasswordExtraStepTitle": "需要额外的步骤", - "xpack.security.management.users.editUser.changePasswordUpdateKibanaTitle": "更改 Kibana 用户的密码后,必须更新 {kibana} 文件并重新启动 Kibana。", "xpack.security.management.users.editUser.changingUserNameAfterCreationDescription": "用户名一经创建,将无法更改。", "xpack.security.management.users.editUser.confirmPasswordFormRowLabel": "确认密码", "xpack.security.management.users.editUser.createUserButtonLabel": "创建用户", @@ -13149,7 +13130,6 @@ "xpack.siem.case.caseView.actionLabel.pushedNewIncident": "已推送为新事件", "xpack.siem.case.caseView.actionLabel.removedField": "移除了", "xpack.siem.case.caseView.actionLabel.updateIncident": "更新了事件", - "xpack.siem.case.caseView.alreadyPushedToService": "已推送到 Service Now 事件", "xpack.siem.case.caseView.backLabel": "返回到案例", "xpack.siem.case.caseView.breadcrumb": "创建", "xpack.siem.case.caseView.cancel": "取消", @@ -13187,7 +13167,6 @@ "xpack.siem.case.caseView.pageBadgeLabel": "公测版", "xpack.siem.case.caseView.pageBadgeTooltip": "案例工作流仍为公测版。请通过在 Kibana 存储库中报告问题或错误,帮助我们改进产品。", "xpack.siem.case.caseView.particpantsLabel": "参与者", - "xpack.siem.case.caseView.pushAsServicenowIncident": "作为 ServiceNow 事件推送", "xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedDescription": "关闭的案例无法发送到外部系统。如果希望在外部系统中打开或更新案例,请重新打开案例。", "xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle": "重新打开案例", "xpack.siem.case.caseView.pushToServiceDisableByConfigDescription": "kibana.yml 文件已配置为仅允许特定连接器。要在外部系统中打开案例,请将 .servicenow 添加到 xpack.actions.enabledActiontypes 设置。有关更多信息,请参阅 {link}。", @@ -13199,11 +13178,9 @@ "xpack.siem.case.caseView.reopenCase": "重新打开案例", "xpack.siem.case.caseView.reopenedCase": "重新打开的案例", "xpack.siem.case.caseView.reporterLabel": "报告者", - "xpack.siem.case.caseView.requiredUpdateToService": "需要更新 ServiceNow 事件", "xpack.siem.case.caseView.statusLabel": "状态", "xpack.siem.case.caseView.tags": "标记", "xpack.siem.case.caseView.to": "到", - "xpack.siem.case.caseView.updatePushAsServicenowIncident": "更新 ServiceNow 事件", "xpack.siem.case.configureCases.addNewConnector": "添加新连接器选项", "xpack.siem.case.configureCases.cancelButton": "取消", "xpack.siem.case.configureCases.caseClosureOptionsClosedIncident": "在第三方系统中关闭事件时自动关闭 SIEM 案例", @@ -13216,10 +13193,6 @@ "xpack.siem.case.configureCases.fieldMappingEditAppend": "追加", "xpack.siem.case.configureCases.fieldMappingEditNothing": "无内容", "xpack.siem.case.configureCases.fieldMappingEditOverwrite": "覆盖", - "xpack.siem.case.configureCases.fieldMappingFieldComments": "注释", - "xpack.siem.case.configureCases.fieldMappingFieldDescription": "描述", - "xpack.siem.case.configureCases.fieldMappingFieldNotMapped": "未映射", - "xpack.siem.case.configureCases.fieldMappingFieldShortDescription": "简短描述", "xpack.siem.case.configureCases.fieldMappingFirstCol": "SIEM 案例字段", "xpack.siem.case.configureCases.fieldMappingSecondCol": "第三方事件字段", "xpack.siem.case.configureCases.fieldMappingThirdCol": "编辑和更新时", @@ -15477,14 +15450,12 @@ "xpack.transform.stepDefineForm.advancedSourceEditorApplyButtonText": "应用更改", "xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel": "高级查询编辑器", "xpack.transform.stepDefineForm.advancedSourceEditorHelpText": "高级编辑器允许您编辑数据帧转换的源查询子句。", - "xpack.transform.stepDefineForm.advancedSourceEditorLabel": "源查询子句", "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalBodyText": "切换回到查询栏,您将会丢失编辑。", "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalConfirmButtonText": "切换至查询栏", "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalTitle": "编辑将会丢失", "xpack.transform.stepDefineForm.aggExistsErrorMessage": "名称为“{aggName}”的聚合配置已存在。", "xpack.transform.stepDefineForm.aggregationsLabel": "聚合", "xpack.transform.stepDefineForm.aggregationsPlaceholder": "添加聚合……", - "xpack.transform.stepDefineForm.formHelp": "转换是用于数据透视的可伸缩和自动化流程。至少选择一个分组依据和聚合,才能开始。", "xpack.transform.stepDefineForm.groupByExistsErrorMessage": "名称为“{aggName}”的分组依据配置已存在。", "xpack.transform.stepDefineForm.groupByLabel": "分组依据", "xpack.transform.stepDefineForm.groupByPlaceholder": "添加分组依据字段……", @@ -15493,8 +15464,6 @@ "xpack.transform.stepDefineForm.nestedAggListConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{aggListName}”有嵌套冲突。", "xpack.transform.stepDefineForm.nestedConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{aggNameCheck}”有嵌套冲突。", "xpack.transform.stepDefineForm.nestedGroupByListConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{groupByListName}”有嵌套冲突。", - "xpack.transform.stepDefineForm.queryHelpText": "使用查询字符串筛选源数据(可选)。", - "xpack.transform.stepDefineForm.queryLabel": "查询", "xpack.transform.stepDefineForm.queryPlaceholderKql": "例如,{example}", "xpack.transform.stepDefineForm.queryPlaceholderLucene": "例如,{example}", "xpack.transform.stepDefineForm.savedSearchLabel": "已保存搜索", @@ -15584,7 +15553,6 @@ "xpack.transform.transformsWizard.cloneTransformTitle": "克隆转换", "xpack.transform.transformsWizard.createTransformTitle": "创建转换", "xpack.transform.transformsWizard.stepCreateTitle": "创建", - "xpack.transform.transformsWizard.stepDefineTitle": "定义透视", "xpack.transform.transformsWizard.stepDetailsTitle": "作业详情", "xpack.transform.transformsWizard.transformDocsLinkText": "转换文档", "xpack.transform.wizard.nextStepButton": "下一个", @@ -15729,7 +15697,6 @@ "xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionName": "删除", "xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actionTypeTitle": "类型", "xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.nameTitle": "名称", - "xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.referencedByCountTitle": "操作", "xpack.triggersActionsUI.sections.actionsConnectorsList.filters.actionTypeIdName": "类型", "xpack.triggersActionsUI.sections.actionsConnectorsList.multipleTitle": "连接器", "xpack.triggersActionsUI.sections.actionsConnectorsList.noPermissionToCreateTitle": "无权创建连接器", @@ -15784,10 +15751,6 @@ "xpack.triggersActionsUI.sections.alertAdd.thresholdPreviewChart.noDataTitle": "没有数据匹配此查询", "xpack.triggersActionsUI.sections.alertAdd.unableToLoadVisualizationMessage": "无法加载可视化", "xpack.triggersActionsUI.sections.alertDetails.alertInstances.disabledAlert": "此告警已禁用,无法显示。切换启用 ↑ 以激活。", - "xpack.triggersActionsUI.sections.alertDetails.alertInstances.mutedAlert": "已静音", - "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.actions.mute": "静音", - "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.actions.unmute": "取消静音", - "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.actions": "操作", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.duration": "持续时间", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.instance": "实例", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.start": "启动", @@ -15795,7 +15758,6 @@ "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.active": "活动", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.inactive": "非活动", "xpack.triggersActionsUI.sections.alertDetails.betaBadgeTooltipContent": "{pluginName} 为公测版,可能会进行更改。设计和代码相对于正式发行版功能还不够成熟,将按原样提供,且不提供任何保证。公测版功能不受正式发行版功能支持 SLA 的约束。", - "xpack.triggersActionsUI.sections.alertDetails.collapsedItemActons.enableTitle": "启用", "xpack.triggersActionsUI.sections.alertDetails.collapsedItemActons.muteTitle": "静音", "xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertMessage": "无法加载告警:{message}", "xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertStateMessage": "无法加载告警状态:{message}", @@ -16002,7 +15964,6 @@ "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteTitle": "您的集群已升级", "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingDescription": "一个或多个 Elasticsearch 节点的 Elasticsearch 版本比 Kibana 版本新。所有节点升级后,请升级 Kibana。", "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingTitle": "您的集群正在升级", - "xpack.uptime.alerts.locationSelectionItem.ariaLabel": "“{location}”的位置选择项", "xpack.uptime.alerts.message.emptyTitle": "未接收到已关闭监测 ID", "xpack.uptime.alerts.message.fullListOverflow": "...以及 {overflowCount} 个其他{pluralizedMonitor}", "xpack.uptime.alerts.message.multipleTitle": "已关闭监测: ", @@ -16010,9 +15971,6 @@ "xpack.uptime.alerts.message.singularTitle": "已关闭监测: ", "xpack.uptime.alerts.monitorStatus": "运行时间监测状态", "xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel": "允许对监测状态告警使用筛选条件的输入", - "xpack.uptime.alerts.monitorStatus.locationSelection": "选择位置 {location}", - "xpack.uptime.alerts.monitorStatus.locationSelectionSwitch.ariaLabel": "选择告警应触发的位置", - "xpack.uptime.alerts.monitorStatus.locationsSelectionExpression.ariaLabel": "打开弹出框以选择告警应触发的位置", "xpack.uptime.alerts.monitorStatus.numTimesExpression.ariaLabel": "打开弹出框以输入已关闭计数", "xpack.uptime.alerts.monitorStatus.numTimesField.ariaLabel": "输入触发告警的已关闭计数", "xpack.uptime.alerts.monitorStatus.timerangeOption.days": "天", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index 40a43f20d44f5..ece1791c66e11 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -1374,7 +1374,7 @@ import { ActionsConnectorsContextProvider, ConnectorAddFlyout } from '../../../. const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); // load required dependancied -const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services; +const { http, triggers_actions_ui, toastNotifications, capabilities, docLinks } = useKibana().services; const connector = { secrets: {}, @@ -1406,6 +1406,7 @@ const connector = { toastNotifications: toastNotifications, actionTypeRegistry: triggers_actions_ui.actionTypeRegistry, capabilities: capabilities, + docLinks, }} > ; capabilities: ApplicationStart['capabilities']; + docLinks: DocLinksStart; reloadConnectors?: () => Promise; } ``` diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx index fdfaf70648694..567e96e05881d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx @@ -11,6 +11,7 @@ import { registerBuiltInActionTypes } from './index'; import { ActionTypeModel, ActionParamsProps } from '../../../types'; import { IndexActionParams, EsIndexActionConnector } from './types'; import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; jest.mock('../../../common/index_controls', () => ({ firstFieldOption: jest.fn(), getFields: jest.fn(), @@ -20,14 +21,35 @@ jest.mock('../../../common/index_controls', () => ({ const ACTION_TYPE_ID = '.index'; let actionTypeModel: ActionTypeModel; +let deps: any; -beforeAll(() => { +beforeAll(async () => { const actionTypeRegistry = new TypeRegistry(); registerBuiltInActionTypes({ actionTypeRegistry }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; } + const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + deps = { + toastNotifications: mocks.notifications.toasts, + http: mocks.http, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, + actionTypeRegistry: actionTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, + }; }); describe('actionTypeRegistry.get() works', () => { @@ -99,8 +121,6 @@ describe('action params validation', () => { describe('IndexActionConnectorFields renders', () => { test('all connector fields is rendered', async () => { - const mocks = coreMock.createSetup(); - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); if (!actionTypeModel.actionConnectorFields) { return; @@ -145,13 +165,25 @@ describe('IndexActionConnectorFields renders', () => { }, } as EsIndexActionConnector; const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - http={mocks.http} - /> + { + return new Promise(() => {}); + }, + docLinks: deps!.docLinks, + }} + > + {}} + editActionSecrets={() => {}} + /> + ); await act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx index 861d6ad7284c2..028638a403893 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx @@ -33,6 +33,7 @@ import { getIndexPatterns, } from '../../../common/index_controls'; import { AddMessageVariables } from '../add_message_variables'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; export function getActionType(): ActionTypeModel { return { @@ -78,7 +79,8 @@ export function getActionType(): ActionTypeModel { const IndexActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, errors, http }) => { +>> = ({ action, editActionConfig, errors }) => { + const { http } = useActionsConnectorsContext(); const { index, refresh, executionTimeField } = action.config; const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( executionTimeField != null diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx index 1c9e87310107f..ae894346be59c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { FunctionComponent } from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; import { TypeRegistry } from '../../type_registry'; import { registerBuiltInActionTypes } from './index'; import { ActionTypeModel, ActionParamsProps } from '../../../types'; @@ -14,17 +16,39 @@ import { SeverityActionOptions, PagerDutyActionConnector, } from './types'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; const ACTION_TYPE_ID = '.pagerduty'; let actionTypeModel: ActionTypeModel; +let deps: any; -beforeAll(() => { +beforeAll(async () => { const actionTypeRegistry = new TypeRegistry(); registerBuiltInActionTypes({ actionTypeRegistry }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; } + const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + deps = { + toastNotifications: mocks.notifications.toasts, + http: mocks.http, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, + actionTypeRegistry: actionTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, + }; }); describe('actionTypeRegistry.get() works', () => { @@ -106,7 +130,7 @@ describe('pagerduty action params validation', () => { }); describe('PagerDutyActionConnectorFields renders', () => { - test('all connector fields is rendered', () => { + test('all connector fields is rendered', async () => { expect(actionTypeModel.actionConnectorFields).not.toBeNull(); if (!actionTypeModel.actionConnectorFields) { return; @@ -124,13 +148,31 @@ describe('PagerDutyActionConnectorFields renders', () => { }, } as PagerDutyActionConnector; const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - /> + { + return new Promise(() => {}); + }, + docLinks: deps!.docLinks, + }} + > + {}} + editActionSecrets={() => {}} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy(); expect( wrapper diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx index e1c30ee1e8146..6f30cd41590ed 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx @@ -25,6 +25,7 @@ import { PagerDutyActionParams, PagerDutyActionConnector } from './types'; import pagerDutySvg from './pagerduty.svg'; import { AddMessageVariables } from '../add_message_variables'; import { hasMustacheTokens } from '../../lib/has_mustache_tokens'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; export function getActionType(): ActionTypeModel { return { @@ -105,6 +106,7 @@ export function getActionType(): ActionTypeModel { const PagerDutyActionConnectorFields: React.FunctionComponent> = ({ errors, action, editActionConfig, editActionSecrets }) => { + const { docLinks } = useActionsConnectorsContext(); const { apiUrl } = action.config; const { routingKey } = action.secrets; return ( @@ -139,7 +141,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent { +let deps: any; + +beforeAll(async () => { const actionTypeRegistry = new TypeRegistry(); registerBuiltInActionTypes({ actionTypeRegistry }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; } + const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + deps = { + toastNotifications: mocks.notifications.toasts, + http: mocks.http, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, + actionTypeRegistry: actionTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, + }; }); describe('actionTypeRegistry.get() works', () => { @@ -78,7 +103,7 @@ describe('slack action params validation', () => { }); describe('SlackActionFields renders', () => { - test('all connector fields is rendered', () => { + test('all connector fields is rendered', async () => { expect(actionTypeModel.actionConnectorFields).not.toBeNull(); if (!actionTypeModel.actionConnectorFields) { return; @@ -94,13 +119,31 @@ describe('SlackActionFields renders', () => { config: {}, } as SlackActionConnector; const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - /> + { + return new Promise(() => {}); + }, + docLinks: deps!.docLinks, + }} + > + {}} + editActionSecrets={() => {}} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy(); expect( wrapper diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx index fd066e172bbfe..1cdde6dd77975 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx @@ -15,6 +15,7 @@ import { } from '../../../types'; import { SlackActionParams, SlackActionConnector } from './types'; import { AddMessageVariables } from '../add_message_variables'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; export function getActionType(): ActionTypeModel { return { @@ -76,6 +77,7 @@ export function getActionType(): ActionTypeModel { const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors }) => { + const { docLinks } = useActionsConnectorsContext(); const { webhookUrl } = action.secrets; return ( @@ -85,7 +87,7 @@ const SlackActionFields: React.FunctionComponent ; capabilities: ApplicationStart['capabilities']; reloadConnectors?: () => Promise; + docLinks: DocLinksStart; } const ActionsConnectorsContext = createContext(null as any); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx index 340370cc0314b..09547f5c8ea66 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx @@ -5,7 +5,13 @@ */ import React, { useContext, createContext } from 'react'; -import { HttpSetup, IUiSettingsClient, ToastsApi, DocLinksStart } from 'kibana/public'; +import { + HttpSetup, + IUiSettingsClient, + ToastsApi, + DocLinksStart, + ApplicationStart, +} from 'kibana/public'; import { ChartsPluginSetup } from 'src/plugins/charts/public'; import { DataPublicPluginSetup } from 'src/plugins/data/public'; import { TypeRegistry } from '../type_registry'; @@ -23,6 +29,7 @@ export interface AlertsContextValue> { uiSettings?: IUiSettingsClient; charts?: ChartsPluginSetup; docLinks: DocLinksStart; + capabilities: ApplicationStart['capabilities']; dataFieldsFormats?: DataPublicPluginSetup['fieldFormats']; metadata?: MetaData; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 1c70e42e7ae72..3b78096c4c644 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -9,11 +9,11 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, ActionConnector } from '../../../types'; import { ActionConnectorForm } from './action_connector_form'; -import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('action_connector_form', () => { - let deps: ActionsConnectorsContextValue; + let deps: any; beforeAll(async () => { const mocks = coreMock.createSetup(); const [ @@ -33,6 +33,7 @@ describe('action_connector_form', () => { }, }, actionTypeRegistry: actionTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -62,14 +63,25 @@ describe('action_connector_form', () => { let wrapper; if (deps) { wrapper = mountWithIntl( - {}} - errors={{ name: [] }} - actionTypeRegistry={deps.actionTypeRegistry} - http={deps.http} - /> + { + return new Promise(() => {}); + }, + docLinks: deps!.docLinks, + }} + > + {}} + errors={{ name: [] }} + /> + ); } const connectorNameField = wrapper?.find('[data-test-subj="nameInput"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 57333d8032793..564b38bd0516a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -15,10 +15,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { HttpSetup } from 'kibana/public'; import { ReducerAction } from './connector_reducer'; -import { ActionConnector, IErrorObject, ActionTypeModel } from '../../../types'; -import { TypeRegistry } from '../../type_registry'; +import { ActionConnector, IErrorObject } from '../../../types'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; export function validateBaseProperties(actionObject: ActionConnector) { const validationResult = { errors: {} }; @@ -47,8 +46,6 @@ interface ActionConnectorProps { body: { message: string; error: string }; }; errors: IErrorObject; - actionTypeRegistry: TypeRegistry; - http: HttpSetup; } export const ActionConnectorForm = ({ @@ -57,9 +54,8 @@ export const ActionConnectorForm = ({ actionTypeName, serverError, errors, - actionTypeRegistry, - http, }: ActionConnectorProps) => { + const { actionTypeRegistry, docLinks } = useActionsConnectorsContext(); const setActionProperty = (key: string, value: any) => { dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); }; @@ -94,7 +90,10 @@ export const ActionConnectorForm = ({ values={{ actionType: actionTypeName, docLink: ( - + ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index aed7d18bd9f3d..cdc187bc6f3ba 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -127,11 +127,25 @@ describe('action_form', () => { isPreconfigured: false, }, ]); - const mockes = coreMock.createSetup(); + const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); deps = { - toastNotifications: mockes.notifications.toasts, - http: mockes.http, + toastNotifications: mocks.notifications.toasts, + http: mocks.http, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, actionTypeRegistry: actionTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; actionTypeRegistry.list.mockReturnValue([ actionType, @@ -224,6 +238,8 @@ describe('action_form', () => { }, ]} toastNotifications={deps!.toastNotifications} + docLinks={deps.docLinks} + capabilities={deps.capabilities} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 531e9e1926ff4..6935dda358d9c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -28,7 +28,7 @@ import { EuiHorizontalRule, EuiText, } from '@elastic/eui'; -import { HttpSetup, ToastsApi } from 'kibana/public'; +import { HttpSetup, ToastsApi, ApplicationStart, DocLinksStart } from 'kibana/public'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; import { IErrorObject, @@ -57,10 +57,12 @@ interface ActionAccordionFormProps { ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; + docLinks: DocLinksStart; actionTypes?: ActionType[]; messageVariables?: string[]; defaultActionMessage?: string; setHasActionsDisabled?: (value: boolean) => void; + capabilities: ApplicationStart['capabilities']; } interface ActiveActionConnectorState { @@ -81,6 +83,8 @@ export const ActionForm = ({ defaultActionMessage, toastNotifications, setHasActionsDisabled, + capabilities, + docLinks, }: ActionAccordionFormProps) => { const [addModalVisible, setAddModalVisibility] = useState(false); const [activeActionItem, setActiveActionItem] = useState( @@ -556,14 +560,14 @@ export const ActionForm = ({ ); return ( - + {checkEnabledResult.isEnabled && keyPadItem} {checkEnabledResult.isEnabled === false && ( {keyPadItem} )} - +
); }); } @@ -656,7 +660,7 @@ export const ActionForm = ({ )}
- + {isLoadingActionTypes ? ( ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 0fb759226c21f..a5e9cdc65cfa6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -6,17 +6,14 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { - ActionsConnectorsContextProvider, - ActionsConnectorsContextValue, -} from '../../context/actions_connectors_context'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ActionTypeMenu } from './action_type_menu'; import { ValidationResult } from '../../../types'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_flyout', () => { - let deps: ActionsConnectorsContextValue; + let deps: any; beforeAll(async () => { const mockes = coreMock.createSetup(); @@ -37,6 +34,7 @@ describe('connector_add_flyout', () => { }, }, actionTypeRegistry: actionTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -68,6 +66,7 @@ describe('connector_add_flyout', () => { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps!.docLinks, }} > { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps!.docLinks, }} > { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps!.docLinks, }} > { - let deps: ActionsConnectorsContextValue; + let deps: any; beforeAll(async () => { const mocks = coreMock.createSetup(); @@ -38,6 +35,7 @@ describe('connector_add_flyout', () => { }, }, actionTypeRegistry: actionTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -56,6 +54,7 @@ describe('connector_add_flyout', () => { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps!.docLinks, }} > { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps!.docLinks, }} > { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps!.docLinks, }} > { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps!.docLinks, }} > ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index d2e3739c1cd22..1b35b5636872d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -9,11 +9,10 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { ConnectorAddModal } from './connector_add_modal'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, ActionType } from '../../../types'; -import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_modal', () => { - let deps: ActionsConnectorsContextValue; + let deps: any; beforeAll(async () => { const mocks = coreMock.createSetup(); @@ -27,13 +26,14 @@ describe('connector_add_modal', () => { http: mocks.http, capabilities: { ...capabilities, - actions: { - delete: true, - save: true, - show: true, + siem: { + 'actions:show': true, + 'actions:save': true, + 'actions:delete': true, }, }, actionTypeRegistry: actionTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); it('renders connector modal form if addModalVisible is true', () => { @@ -63,19 +63,19 @@ describe('connector_add_modal', () => { minimumLicenseRequired: 'basic', }; - const wrapper = deps - ? mountWithIntl( - {}} - actionType={actionType} - http={deps.http} - actionTypeRegistry={deps.actionTypeRegistry} - toastNotifications={deps.toastNotifications} - /> - ) - : undefined; - expect(wrapper?.find('EuiModalHeader')).toHaveLength(1); - expect(wrapper?.find('[data-test-subj="saveActionButtonModal"]').exists()).toBeTruthy(); + const wrapper = mountWithIntl( + {}} + actionType={actionType} + http={deps!.http} + actionTypeRegistry={deps!.actionTypeRegistry} + toastNotifications={deps!.toastNotifications} + docLinks={deps!.docLinks} + capabilities={deps!.capabilities} + /> + ); + expect(wrapper.exists('.euiModalHeader')).toBeTruthy(); + expect(wrapper.exists('[data-test-subj="saveActionButtonModal"]')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index e04484b897e1c..a31336f38bdcd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -17,7 +17,7 @@ import { import { EuiButtonEmpty } from '@elastic/eui'; import { EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { HttpSetup, ToastsApi } from 'kibana/public'; +import { HttpSetup, ToastsApi, ApplicationStart, DocLinksStart } from 'kibana/public'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; import { ActionType, ActionConnector, IErrorObject, ActionTypeModel } from '../../../types'; import { connectorReducer } from './connector_reducer'; @@ -25,6 +25,8 @@ import { createActionConnector } from '../../lib/action_connector_api'; import { TypeRegistry } from '../../type_registry'; import './connector_add_modal.scss'; import { PLUGIN } from '../../constants/plugin'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; interface ConnectorAddModalProps { actionType: ActionType; @@ -33,10 +35,12 @@ interface ConnectorAddModalProps { postSaveEventHandler?: (savedAction: ActionConnector) => void; http: HttpSetup; actionTypeRegistry: TypeRegistry; - toastNotifications?: Pick< + toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; + capabilities: ApplicationStart['capabilities']; + docLinks: DocLinksStart; } export const ConnectorAddModal = ({ @@ -47,6 +51,8 @@ export const ConnectorAddModal = ({ http, toastNotifications, actionTypeRegistry, + capabilities, + docLinks, }: ConnectorAddModalProps) => { let hasErrors = false; const initialConnector = { @@ -55,6 +61,7 @@ export const ConnectorAddModal = ({ secrets: {}, } as ActionConnector; const [isSaving, setIsSaving] = useState(false); + const canSave = hasSaveActionsCapability(capabilities); const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: initialConnector }); const setConnector = (value: any) => { @@ -149,17 +156,24 @@ export const ConnectorAddModal = ({ - + + + - {i18n.translate( @@ -169,32 +183,33 @@ export const ConnectorAddModal = ({ } )} - - { - setIsSaving(true); - const savedAction = await onActionConnectorSave(); - setIsSaving(false); - if (savedAction) { - if (postSaveEventHandler) { - postSaveEventHandler(savedAction); + {canSave ? ( + { + setIsSaving(true); + const savedAction = await onActionConnectorSave(); + setIsSaving(false); + if (savedAction) { + if (postSaveEventHandler) { + postSaveEventHandler(savedAction); + } + closeModal(); } - closeModal(); - } - }} - > - - + }} + > + + + ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 4dba4c70f794f..976ec146181c2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -37,6 +37,7 @@ describe('connector_edit_flyout', () => { }, actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: {} as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -80,6 +81,7 @@ describe('connector_edit_flyout', () => { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps.docLinks, }} > { reloadConnectors: () => { return new Promise(() => {}); }, + docLinks: deps.docLinks, }} > setEditFlyoutVisibility(false), [setEditFlyoutVisibility]); @@ -181,8 +182,6 @@ export const ConnectorEditFlyout = ({ errors={errors} actionTypeName={connector.actionType} dispatch={dispatch} - actionTypeRegistry={actionTypeRegistry} - http={http} /> ) : ( @@ -194,7 +193,10 @@ export const ConnectorEditFlyout = ({ } )} - + { - const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); + const { + http, + toastNotifications, + capabilities, + actionTypeRegistry, + docLinks, + } = useAppDependencies(); const canDelete = hasDeleteActionsCapability(capabilities); const canSave = hasSaveActionsCapability(capabilities); @@ -185,23 +190,6 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { sortable: false, truncateText: true, }, - { - field: 'referencedByCount', - 'data-test-subj': 'connectorsTableCell-referencedByCount', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.referencedByCountTitle', - { defaultMessage: 'Actions' } - ), - sortable: false, - truncateText: true, - render: (value: number, item: ActionConnectorTableItem) => { - return ( - - {value} - - ); - }, - }, { field: 'isPreconfigured', name: '', @@ -412,6 +400,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { capabilities, toastNotifications, reloadConnectors: loadActions, + docLinks, }} > { }); it('renders a counter for multiple alert action', () => { - const actionCount = random(1, 10); const alert = mockAlert({ actions: [ { @@ -184,12 +182,12 @@ describe('alert_details', () => { params: {}, actionTypeId: '.server-log', }, - ...times(actionCount, () => ({ + { group: 'default', id: uuid.v4(), params: {}, actionTypeId: '.email', - })), + }, ], }); const alertType = { @@ -238,7 +236,7 @@ describe('alert_details', () => { expect( details.containsMatchingElement( - {`+${actionCount}`} + {actionTypes[1].name} ) ).toBeTruthy(); @@ -288,8 +286,8 @@ describe('alert_details', () => { }); }); -describe('enable button', () => { - it('should render an enable button when alert is enabled', () => { +describe('disable button', () => { + it('should render a disable button when alert is enabled', () => { const alert = mockAlert({ enabled: true, }); @@ -306,16 +304,16 @@ describe('enable button', () => { ) .find(EuiSwitch) - .find('[name="enable"]') + .find('[name="disable"]') .first(); expect(enableButton.props()).toMatchObject({ - checked: true, + checked: false, disabled: false, }); }); - it('should render an enable button when alert is disabled', () => { + it('should render a disable button when alert is disabled', () => { const alert = mockAlert({ enabled: false, }); @@ -332,11 +330,11 @@ describe('enable button', () => { ) .find(EuiSwitch) - .find('[name="enable"]') + .find('[name="disable"]') .first(); expect(enableButton.props()).toMatchObject({ - checked: false, + checked: true, disabled: false, }); }); @@ -365,7 +363,7 @@ describe('enable button', () => { /> ) .find(EuiSwitch) - .find('[name="enable"]') + .find('[name="disable"]') .first(); enableButton.simulate('click'); @@ -400,7 +398,7 @@ describe('enable button', () => { /> ) .find(EuiSwitch) - .find('[name="enable"]') + .find('[name="disable"]') .first(); enableButton.simulate('click'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 318dd28d92da1..3440bb28b2468 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -13,6 +13,7 @@ import { EuiPageContentHeader, EuiPageContentHeaderSection, EuiTitle, + EuiText, EuiFlexGroup, EuiFlexItem, EuiBadge, @@ -73,8 +74,9 @@ export const AlertDetails: React.FunctionComponent = ({ const canSave = hasSaveAlertsCapability(capabilities); const actionTypesByTypeId = indexBy(actionTypes, 'id'); - const [firstAction, ...otherActions] = alert.actions; + const alertActions = alert.actions; + const uniqueActions = Array.from(new Set(alertActions.map((item: any) => item.actionTypeId))); const [isEnabled, setIsEnabled] = useState(alert.enabled); const [isMuted, setIsMuted] = useState(alert.muteAll); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); @@ -136,6 +138,7 @@ export const AlertDetails: React.FunctionComponent = ({ charts, dataFieldsFormats: dataPlugin.fieldFormats, reloadAlerts: setAlert, + capabilities, }} > = ({ - - - {alertType.name} - - {firstAction && ( - - - {actionTypesByTypeId[firstAction.actionTypeId].name ?? - firstAction.actionTypeId} - - - )} - {otherActions.length ? ( - - +{otherActions.length} - - ) : null} - + +

+ +

+
+ + {alertType.name}
- - + + {uniqueActions && uniqueActions.length ? ( + + +

+ +

+
+ + + {uniqueActions.map((action, index) => ( + + + {actionTypesByTypeId[action].name ?? action} + + + ))} + +
+ ) : null} +
+ + + { if (isEnabled) { setIsEnabled(false); @@ -195,8 +215,8 @@ export const AlertDetails: React.FunctionComponent = ({ }} label={ } /> @@ -229,19 +249,21 @@ export const AlertDetails: React.FunctionComponent = ({ - {alert.enabled ? ( ) : ( - -

- -

-
+ + + +

+ +

+
+
)}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx index fa4d8f66cd7bf..16c6fb092f2e5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx @@ -7,11 +7,10 @@ import React, { Fragment, useState } from 'react'; import moment, { Duration } from 'moment'; import { i18n } from '@kbn/i18n'; -import { EuiBasicTable, EuiButtonToggle, EuiBadge, EuiHealth } from '@elastic/eui'; +import { EuiBasicTable, EuiHealth, EuiSpacer, EuiSwitch } from '@elastic/eui'; // @ts-ignore import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services'; import { padLeft, difference, chunk } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; import { Alert, AlertTaskState, RawAlertInstance, Pagination } from '../../../../types'; import { ComponentOpts as AlertApis, @@ -80,40 +79,19 @@ export const alertInstancesTableColumns = ( field: '', align: RIGHT_ALIGNMENT, name: i18n.translate( - 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.actions', - { defaultMessage: 'Actions' } + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.mute', + { defaultMessage: 'Mute' } ), render: (alertInstance: AlertInstanceListItem) => { return ( - {alertInstance.isMuted ? ( - - - - ) : ( - - )} - onMuteAction(alertInstance)} - isSelected={alertInstance.isMuted} - isEmpty - isIconOnly /> ); @@ -161,6 +139,7 @@ export function AlertInstances({ return ( + { let wrapper: ReactWrapper; async function setup() { - const mockes = coreMock.createSetup(); + const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); deps = { - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, + toastNotifications: mocks.notifications.toasts, + http: mocks.http, + uiSettings: mocks.uiSettings, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), actionTypeRegistry: actionTypeRegistry as any, @@ -53,7 +58,7 @@ describe('alert_add', () => { docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; - mockes.http.get.mockResolvedValue({ + mocks.http.get.mockResolvedValue({ isSufficientlySecure: true, hasPermanentEncryptionKey: true, }); @@ -104,6 +109,14 @@ describe('alert_add', () => { uiSettings: deps.uiSettings, docLinks: deps.docLinks, metadata: { test: 'some value', fields: ['test'] }, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, }} > { }); async function setup() { + const [ + { + application: { capabilities }, + }, + ] = await mockedCoreSetup.getStartServices(); deps = { toastNotifications: mockedCoreSetup.notifications.toasts, http: mockedCoreSetup.http, @@ -34,6 +39,7 @@ describe('alert_edit', () => { actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, + capabilities, }; mockedCoreSetup.http.get.mockResolvedValue({ @@ -122,6 +128,7 @@ describe('alert_edit', () => { toastNotifications: deps!.toastNotifications, uiSettings: deps!.uiSettings, docLinks: deps.docLinks, + capabilities: deps!.capabilities, }} > { let wrapper: ReactWrapper; async function setup() { - const mockes = coreMock.createSetup(); + const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); deps = { - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, + toastNotifications: mocks.notifications.toasts, + http: mocks.http, + uiSettings: mocks.uiSettings, actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, + capabilities, }; alertTypeRegistry.list.mockReturnValue([alertType]); alertTypeRegistry.has.mockReturnValue(true); @@ -86,6 +92,7 @@ describe('alert_form', () => { alertTypeRegistry: deps!.alertTypeRegistry, toastNotifications: deps!.toastNotifications, uiSettings: deps!.uiSettings, + capabilities: deps!.capabilities, }} > {}} errors={{ name: [], interval: [] }} /> @@ -166,6 +173,7 @@ describe('alert_form', () => { alertTypeRegistry: deps!.alertTypeRegistry, toastNotifications: deps!.toastNotifications, uiSettings: deps!.uiSettings, + capabilities: deps!.capabilities, }} > {}} errors={{ name: [], interval: [] }} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 4b8045d1bc8a4..3b7283e69e019 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -87,7 +87,14 @@ export const AlertForm = ({ setHasActionsDisabled, }: AlertFormProps) => { const alertsContext = useAlertsContext(); - const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = alertsContext; + const { + http, + toastNotifications, + alertTypeRegistry, + actionTypeRegistry, + docLinks, + capabilities, + } = alertsContext; const [alertTypeModel, setAlertTypeModel] = useState( alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null @@ -245,6 +252,8 @@ export const AlertForm = ({ actionTypeRegistry={actionTypeRegistry} defaultActionMessage={alertTypeModel?.defaultActionMessage} toastNotifications={toastNotifications} + docLinks={docLinks} + capabilities={capabilities} /> ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 2d9cfcdbda89f..fa83176a14344 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -9,6 +9,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useEffect, useState, Fragment } from 'react'; import { EuiBasicTable, + EuiBadge, EuiButton, EuiFieldText, EuiFlexGroup, @@ -192,6 +193,22 @@ export const AlertsList: React.FunctionComponent = () => { sortable: false, 'data-test-subj': 'alertsTableCell-tagsText', }, + { + field: 'actionsText', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.actionsText', + { defaultMessage: 'Actions' } + ), + render: (count: number, item: AlertTableItem) => { + return ( + + {count} + + ); + }, + sortable: false, + 'data-test-subj': 'alertsTableCell-actionsText', + }, { field: 'alertType', name: i18n.translate( @@ -418,6 +435,7 @@ export const AlertsList: React.FunctionComponent = () => { docLinks, charts, dataFieldsFormats: dataPlugin.fieldFormats, + capabilities, }} > ({ ...alert, + actionsText: alert.actions.length, tagsText: alert.tags.join(', '), alertType: alertTypesIndex[alert.alertTypeId]?.name ?? alert.alertTypeId, })); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 7f78d327d0122..47cb7067296ce 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -104,7 +104,7 @@ export interface AlertTableItem extends Alert { export interface AlertTypeModel { id: string; - name: string; + name: string | JSX.Element; iconClass: string; validate: (alertParams: any) => ValidationResult; alertParamsExpression: React.FunctionComponent; diff --git a/x-pack/plugins/uptime/common/constants/settings_defaults.ts b/x-pack/plugins/uptime/common/constants/settings_defaults.ts index b7986679a09ca..82575e875577b 100644 --- a/x-pack/plugins/uptime/common/constants/settings_defaults.ts +++ b/x-pack/plugins/uptime/common/constants/settings_defaults.ts @@ -8,8 +8,6 @@ import { DynamicSettings } from '../runtime_types'; export const DYNAMIC_SETTINGS_DEFAULTS: DynamicSettings = { heartbeatIndices: 'heartbeat-8*', - certThresholds: { - expiration: 30, - age: 365, - }, + certAgeThreshold: 365, + certExpirationThreshold: 30, }; diff --git a/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts b/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts index da887cc5055c1..a0ec92f7d869b 100644 --- a/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts +++ b/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts @@ -6,14 +6,10 @@ import * as t from 'io-ts'; -export const CertStateThresholdsType = t.type({ - age: t.number, - expiration: t.number, -}); - export const DynamicSettingsType = t.type({ heartbeatIndices: t.string, - certThresholds: CertStateThresholdsType, + certAgeThreshold: t.number, + certExpirationThreshold: t.number, }); export const DynamicSettingsSaveType = t.intersection([ @@ -27,4 +23,3 @@ export const DynamicSettingsSaveType = t.intersection([ export type DynamicSettings = t.TypeOf; export type DynamicSettingsSaveResponse = t.TypeOf; -export type CertStateThresholds = t.TypeOf; diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts b/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts index 2c3b52051be0f..209770a19f4aa 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts @@ -21,7 +21,6 @@ export interface GetPingHistogramParams { dateEnd: string; filters?: string; monitorId?: string; - statusFilter?: string; } export interface HistogramResult { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap index fa9b59e13c34e..9ad61f50b0521 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap @@ -15,10 +15,8 @@ exports[`ML Integrations renders without errors 1`] = ` -