diff --git a/docs/development/core/server/kibana-plugin-server.destructiveroutemethod.md b/docs/development/core/server/kibana-plugin-server.destructiveroutemethod.md new file mode 100644 index 0000000000000..48b1e837f6db9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.destructiveroutemethod.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [DestructiveRouteMethod](./kibana-plugin-server.destructiveroutemethod.md) + +## DestructiveRouteMethod type + +Set of HTTP methods changing the state of the server. + +Signature: + +```typescript +export declare type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch'; +``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index c948c89920796..0e79385d1ca4d 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -188,6 +188,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ConfigDeprecationLogger](./kibana-plugin-server.configdeprecationlogger.md) | Logger interface used when invoking a [ConfigDeprecation](./kibana-plugin-server.configdeprecation.md) | | [ConfigDeprecationProvider](./kibana-plugin-server.configdeprecationprovider.md) | A provider that should returns a list of [ConfigDeprecation](./kibana-plugin-server.configdeprecation.md).See [ConfigDeprecationFactory](./kibana-plugin-server.configdeprecationfactory.md) for more usage examples. | | [ConfigPath](./kibana-plugin-server.configpath.md) | | +| [DestructiveRouteMethod](./kibana-plugin-server.destructiveroutemethod.md) | Set of HTTP methods changing the state of the server. | | [ElasticsearchClientConfig](./kibana-plugin-server.elasticsearchclientconfig.md) | | | [GetAuthHeaders](./kibana-plugin-server.getauthheaders.md) | Get headers to authenticate a user against Elasticsearch. | | [GetAuthState](./kibana-plugin-server.getauthstate.md) | Gets authentication state for a request. Returned by auth interceptor. | @@ -232,6 +233,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [RouteValidationFunction](./kibana-plugin-server.routevalidationfunction.md) | The custom validation function if @kbn/config-schema is not a valid solution for your specific plugin requirements. | | [RouteValidationSpec](./kibana-plugin-server.routevalidationspec.md) | Allowed property validation options: either @kbn/config-schema validations or custom validation functionsSee [RouteValidationFunction](./kibana-plugin-server.routevalidationfunction.md) for custom validation. | | [RouteValidatorFullConfig](./kibana-plugin-server.routevalidatorfullconfig.md) | Route validations config and options merged into one object | +| [SafeRouteMethod](./kibana-plugin-server.saferoutemethod.md) | Set of HTTP methods not changing the state of the server. | | [SavedObjectAttribute](./kibana-plugin-server.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-server.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-server.savedobjectattribute.md) | | [SavedObjectMigrationFn](./kibana-plugin-server.savedobjectmigrationfn.md) | A migration function for a [saved object type](./kibana-plugin-server.savedobjectstype.md) used to migrate it to a given version | diff --git a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md index 0929e15b6228b..7fbab90cc2c8a 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md @@ -19,4 +19,5 @@ export interface RouteConfigOptions | [authRequired](./kibana-plugin-server.routeconfigoptions.authrequired.md) | boolean | A flag shows that authentication for a route: enabled when true disabled when falseEnabled by default. | | [body](./kibana-plugin-server.routeconfigoptions.body.md) | Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody | Additional body options [RouteConfigOptionsBody](./kibana-plugin-server.routeconfigoptionsbody.md). | | [tags](./kibana-plugin-server.routeconfigoptions.tags.md) | readonly string[] | Additional metadata tag strings to attach to the route. | +| [xsrfRequired](./kibana-plugin-server.routeconfigoptions.xsrfrequired.md) | Method extends 'get' ? never : boolean | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain kbn-xsrf header. - false. Disables xsrf protection.Set to true by default | diff --git a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.xsrfrequired.md b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.xsrfrequired.md new file mode 100644 index 0000000000000..801a0c3dc299b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.xsrfrequired.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md) > [xsrfRequired](./kibana-plugin-server.routeconfigoptions.xsrfrequired.md) + +## RouteConfigOptions.xsrfRequired property + +Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain `kbn-xsrf` header. - false. Disables xsrf protection. + +Set to true by default + +Signature: + +```typescript +xsrfRequired?: Method extends 'get' ? never : boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.routemethod.md b/docs/development/core/server/kibana-plugin-server.routemethod.md index 939ae94b85691..ed0d8e9af4b19 100644 --- a/docs/development/core/server/kibana-plugin-server.routemethod.md +++ b/docs/development/core/server/kibana-plugin-server.routemethod.md @@ -9,5 +9,5 @@ The set of common HTTP methods supported by Kibana routing. Signature: ```typescript -export declare type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options'; +export declare type RouteMethod = SafeRouteMethod | DestructiveRouteMethod; ``` diff --git a/docs/development/core/server/kibana-plugin-server.saferoutemethod.md b/docs/development/core/server/kibana-plugin-server.saferoutemethod.md new file mode 100644 index 0000000000000..432aa4c6e7014 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.saferoutemethod.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SafeRouteMethod](./kibana-plugin-server.saferoutemethod.md) + +## SafeRouteMethod type + +Set of HTTP methods not changing the state of the server. + +Signature: + +```typescript +export declare type SafeRouteMethod = 'get' | 'options'; +``` diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts new file mode 100644 index 0000000000000..9a51937cbac1e --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts @@ -0,0 +1,20 @@ +/* + * 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 foo() {} diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts index 9d3871df24739..1ba0b69681152 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts @@ -19,3 +19,7 @@ export * from './lib'; export * from './ext'; + +export async function getFoo() { + return await import('./async_import'); +} diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 1a974d3e81092..d52d89eebe2f1 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`builds expected bundles, saves bundle counts to metadata: 1 async bundle 1`] = `"(window[\\"foo_bundle_jsonpfunction\\"]=window[\\"foo_bundle_jsonpfunction\\"]||[]).push([[1],{3:function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.foo=foo;function foo(){}}}]);"`; + exports[`builds expected bundles, saves bundle counts to metadata: OptimizerConfig 1`] = ` OptimizerConfig { "bundles": Array [ @@ -55,6 +57,6 @@ OptimizerConfig { } `; -exports[`builds expected bundles, saves bundle counts to metadata: bar bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/bar\\"]=function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"__REPLACE_WITH_PUBLIC_PATH__\\";return __webpack_require__(__webpack_require__.s=4)}([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i { Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/foo.plugin.js'), 'utf8') ).toMatchSnapshot('foo bundle'); + expect( + Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/1.plugin.js'), 'utf8') + ).toMatchSnapshot('1 async bundle'); + expect( Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/bar/target/public/bar.plugin.js'), 'utf8') ).toMatchSnapshot('bar bundle'); @@ -135,9 +139,10 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { const foo = config.bundles.find(b => b.id === 'foo')!; expect(foo).toBeTruthy(); foo.cache.refresh(); - expect(foo.cache.getModuleCount()).toBe(3); + expect(foo.cache.getModuleCount()).toBe(4); expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, @@ -148,8 +153,8 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { expect(bar).toBeTruthy(); bar.cache.refresh(); expect(bar.cache.getModuleCount()).toBe( - // code + styles + style/css-loader runtime - 14 + // code + styles + style/css-loader runtimes + 15 ); expect(bar.cache.getReferencedFiles()).toMatchInlineSnapshot(` @@ -159,6 +164,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts index 7a8097fd2b2c7..e87ddc7d0185c 100644 --- a/packages/kbn-optimizer/src/worker/run_compilers.ts +++ b/packages/kbn-optimizer/src/worker/run_compilers.ts @@ -127,7 +127,7 @@ const observeCompiler = ( ); } - const files = Array.from(referencedFiles); + const files = Array.from(referencedFiles).sort(ascending(p => p)); const mtimes = new Map( files.map((path): [string, number | undefined] => { try { @@ -146,7 +146,7 @@ const observeCompiler = ( optimizerCacheKey: workerConfig.optimizerCacheKey, cacheKey: bundle.createCacheKey(files, mtimes), moduleCount: normalModules.length, - files: files.sort(ascending(f => f)), + files, }); return compilerMsgs.compilerSuccess({ diff --git a/packages/kbn-test/src/failed_tests_reporter/github_api.ts b/packages/kbn-test/src/failed_tests_reporter/github_api.ts index d8a952bee42e5..7da79b5b67e63 100644 --- a/packages/kbn-test/src/failed_tests_reporter/github_api.ts +++ b/packages/kbn-test/src/failed_tests_reporter/github_api.ts @@ -33,6 +33,15 @@ export interface GithubIssue { body: string; } +/** + * Minimal GithubIssue type that can be easily replicated by dry-run helpers + */ +export interface GithubIssueMini { + number: GithubIssue['number']; + body: GithubIssue['body']; + html_url: GithubIssue['html_url']; +} + type RequestOptions = AxiosRequestConfig & { safeForDryRun?: boolean; maxAttempts?: number; @@ -162,7 +171,7 @@ export class GithubApi { } async createIssue(title: string, body: string, labels?: string[]) { - const resp = await this.request( + const resp = await this.request( { method: 'POST', url: Url.resolve(BASE_URL, 'issues'), @@ -173,11 +182,13 @@ export class GithubApi { }, }, { + body, + number: 999, html_url: 'https://dryrun', } ); - return resp.data.html_url; + return resp.data; } private async request( diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts b/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts index ef6ab3c51ab19..5bbc72fe04e86 100644 --- a/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts +++ b/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts @@ -78,9 +78,7 @@ describe('updateFailureIssue()', () => { 'https://build-url', { html_url: 'https://github.com/issues/1234', - labels: ['some-label'], number: 1234, - title: 'issue title', body: dedent` # existing issue body diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failure.ts b/packages/kbn-test/src/failed_tests_reporter/report_failure.ts index 97e9d517576fc..1413d05498459 100644 --- a/packages/kbn-test/src/failed_tests_reporter/report_failure.ts +++ b/packages/kbn-test/src/failed_tests_reporter/report_failure.ts @@ -18,7 +18,7 @@ */ import { TestFailure } from './get_failures'; -import { GithubIssue, GithubApi } from './github_api'; +import { GithubIssueMini, GithubApi } from './github_api'; import { getIssueMetadata, updateIssueMetadata } from './issue_metadata'; export async function createFailureIssue(buildUrl: string, failure: TestFailure, api: GithubApi) { @@ -44,7 +44,7 @@ export async function createFailureIssue(buildUrl: string, failure: TestFailure, return await api.createIssue(title, body, ['failed-test']); } -export async function updateFailureIssue(buildUrl: string, issue: GithubIssue, api: GithubApi) { +export async function updateFailureIssue(buildUrl: string, issue: GithubIssueMini, api: GithubApi) { // Increment failCount const newCount = getIssueMetadata(issue.body, 'test.failCount', 0) + 1; const newBody = updateIssueMetadata(issue.body, { diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index fc52fa6cbf9e7..9324f9eb42aa5 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -20,8 +20,8 @@ import { REPO_ROOT, run, createFailError, createFlagError } from '@kbn/dev-utils'; import globby from 'globby'; -import { getFailures } from './get_failures'; -import { GithubApi } from './github_api'; +import { getFailures, TestFailure } from './get_failures'; +import { GithubApi, GithubIssueMini } from './github_api'; import { updateFailureIssue, createFailureIssue } from './report_failure'; import { getIssueMetadata } from './issue_metadata'; import { readTestReport } from './test_report'; @@ -73,6 +73,11 @@ export function runFailedTestsReporterCli() { absolute: true, }); + const newlyCreatedIssues: Array<{ + failure: TestFailure; + newIssue: GithubIssueMini; + }> = []; + for (const reportPath of reportPaths) { const report = await readTestReport(reportPath); const messages = Array.from(getReportMessageIter(report)); @@ -94,12 +99,22 @@ export function runFailedTestsReporterCli() { continue; } - const existingIssue = await githubApi.findFailedTestIssue( + let existingIssue: GithubIssueMini | undefined = await githubApi.findFailedTestIssue( i => getIssueMetadata(i.body, 'test.class') === failure.classname && getIssueMetadata(i.body, 'test.name') === failure.name ); + if (!existingIssue) { + const newlyCreated = newlyCreatedIssues.find( + ({ failure: f }) => f.classname === failure.classname && f.name === failure.name + ); + + if (newlyCreated) { + existingIssue = newlyCreated.newIssue; + } + } + if (existingIssue) { const newFailureCount = await updateFailureIssue(buildUrl, existingIssue, githubApi); const url = existingIssue.html_url; @@ -110,11 +125,12 @@ export function runFailedTestsReporterCli() { continue; } - const newIssueUrl = await createFailureIssue(buildUrl, failure, githubApi); + const newIssue = await createFailureIssue(buildUrl, failure, githubApi); pushMessage('Test has not failed recently on tracked branches'); if (updateGithub) { - pushMessage(`Created new issue: ${newIssueUrl}`); + pushMessage(`Created new issue: ${newIssue.html_url}`); } + newlyCreatedIssues.push({ failure, newIssue }); } // mutates report to include messages and writes updated report to disk diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index b40dbdc1b6651..a91e128f62d2d 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -81,6 +81,19 @@ describe('core deprecations', () => { }); }); + describe('xsrfDeprecation', () => { + it('logs a warning if server.xsrf.whitelist is set', () => { + const { messages } = applyCoreDeprecations({ + server: { xsrf: { whitelist: ['/path'] } }, + }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "It is not recommended to disable xsrf protections for API endpoints via [server.xsrf.whitelist]. It will be removed in 8.0 release. Instead, supply the \\"kbn-xsrf\\" header.", + ] + `); + }); + }); + describe('rewriteBasePath', () => { it('logs a warning is server.basePath is set and server.rewriteBasePath is not', () => { const { messages } = applyCoreDeprecations({ diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 4fa51dcd5a082..d91e55115d0b1 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -38,6 +38,19 @@ const dataPathDeprecation: ConfigDeprecation = (settings, fromPath, log) => { return settings; }; +const xsrfDeprecation: ConfigDeprecation = (settings, fromPath, log) => { + if ( + has(settings, 'server.xsrf.whitelist') && + get(settings, 'server.xsrf.whitelist').length > 0 + ) { + log( + 'It is not recommended to disable xsrf protections for API endpoints via [server.xsrf.whitelist]. ' + + 'It will be removed in 8.0 release. Instead, supply the "kbn-xsrf" header.' + ); + } + return settings; +}; + const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, log) => { if (has(settings, 'server.basePath') && !has(settings, 'server.rewriteBasePath')) { log( @@ -177,4 +190,5 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ rewriteBasePathDeprecation, cspRulesDeprecation, mapManifestServiceUrlDeprecation, + xsrfDeprecation, ]; diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 0a9541393284e..741c723ca9365 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -29,6 +29,7 @@ import { RouteMethod, KibanaResponseFactory, RouteValidationSpec, + KibanaRouteState, } from './router'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; @@ -43,6 +44,7 @@ interface RequestFixtureOptions

{ method?: RouteMethod; socket?: Socket; routeTags?: string[]; + kibanaRouteState?: KibanaRouteState; routeAuthRequired?: false; validation?: { params?: RouteValidationSpec

; @@ -62,6 +64,7 @@ function createKibanaRequestMock

({ routeTags, routeAuthRequired, validation = {}, + kibanaRouteState = { xsrfRequired: true }, }: RequestFixtureOptions = {}) { const queryString = stringify(query, { sort: false }); @@ -80,7 +83,7 @@ function createKibanaRequestMock

({ search: queryString ? `?${queryString}` : queryString, }, route: { - settings: { tags: routeTags, auth: routeAuthRequired }, + settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteState }, }, raw: { req: { socket }, @@ -109,6 +112,7 @@ function createRawRequestMock(customization: DeepPartial = {}) { return merge( {}, { + app: { xsrfRequired: true } as any, headers: {}, path: '/', route: { settings: {} }, diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index a9fc80c86d878..27db79bb94d25 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -811,6 +811,7 @@ test('exposes route details of incoming request to a route handler', async () => path: '/', options: { authRequired: true, + xsrfRequired: false, tags: [], }, }); @@ -923,6 +924,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo path: '/', options: { authRequired: true, + xsrfRequired: true, tags: [], body: { parse: true, // hapi populates the default diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 025ab2bf56ac2..cffdffab0d0cf 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -27,7 +27,7 @@ import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_p import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response'; -import { IRouter } from './router'; +import { IRouter, KibanaRouteState, isSafeMethod } from './router'; import { SessionStorageCookieOptions, createCookieSessionStorageFactory, @@ -147,9 +147,14 @@ export class HttpServer { for (const route of router.getRoutes()) { this.log.debug(`registering route handler for [${route.path}]`); // Hapi does not allow payload validation to be specified for 'head' or 'get' requests - const validate = ['head', 'get'].includes(route.method) ? undefined : { payload: true }; + const validate = isSafeMethod(route.method) ? undefined : { payload: true }; const { authRequired = true, tags, body = {} } = route.options; const { accepts: allow, maxBytes, output, parse } = body; + + const kibanaRouteState: KibanaRouteState = { + xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), + }; + this.server.route({ handler: route.handler, method: route.method, @@ -157,6 +162,7 @@ export class HttpServer { options: { // Enforcing the comparison with true because plugins could overwrite the auth strategy by doing `options: { authRequired: authStrategy as any }` auth: authRequired === true ? undefined : false, + app: kibanaRouteState, tags: tags ? Array.from(tags) : undefined, // TODO: This 'validate' section can be removed once the legacy platform is completely removed. // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index d31afe1670e41..8f4c02680f8a3 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -58,6 +58,8 @@ export { RouteValidationError, RouteValidatorFullConfig, RouteValidationResultFactory, + DestructiveRouteMethod, + SafeRouteMethod, } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index f4c5f16870c7e..b5364c616f17c 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -36,6 +36,7 @@ const versionHeader = 'kbn-version'; const xsrfHeader = 'kbn-xsrf'; const nameHeader = 'kbn-name'; const whitelistedTestPath = '/xsrf/test/route/whitelisted'; +const xsrfDisabledTestPath = '/xsrf/test/route/disabled'; const kibanaName = 'my-kibana-name'; const setupDeps = { context: contextServiceMock.createSetupContract(), @@ -188,6 +189,12 @@ describe('core lifecycle handlers', () => { return res.ok({ body: 'ok' }); } ); + ((router as any)[method.toLowerCase()] as RouteRegistrar)( + { path: xsrfDisabledTestPath, validate: false, options: { xsrfRequired: false } }, + (context, req, res) => { + return res.ok({ body: 'ok' }); + } + ); }); await server.start(); @@ -235,6 +242,10 @@ describe('core lifecycle handlers', () => { it('accepts whitelisted requests without either an xsrf or version header', async () => { await getSupertest(method.toLowerCase(), whitelistedTestPath).expect(200, 'ok'); }); + + it('accepts requests on a route with disabled xsrf protection', async () => { + await getSupertest(method.toLowerCase(), xsrfDisabledTestPath).expect(200, 'ok'); + }); }); }); }); diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts index 48a6973b741ba..a80e432e0d4cb 100644 --- a/src/core/server/http/lifecycle_handlers.test.ts +++ b/src/core/server/http/lifecycle_handlers.test.ts @@ -24,7 +24,7 @@ import { } from './lifecycle_handlers'; import { httpServerMock } from './http_server.mocks'; import { HttpConfig } from './http_config'; -import { KibanaRequest, RouteMethod } from './router'; +import { KibanaRequest, RouteMethod, KibanaRouteState } from './router'; const createConfig = (partial: Partial): HttpConfig => partial as HttpConfig; @@ -32,12 +32,14 @@ const forgeRequest = ({ headers = {}, path = '/', method = 'get', + kibanaRouteState, }: Partial<{ headers: Record; path: string; method: RouteMethod; + kibanaRouteState: KibanaRouteState; }>): KibanaRequest => { - return httpServerMock.createKibanaRequest({ headers, path, method }); + return httpServerMock.createKibanaRequest({ headers, path, method, kibanaRouteState }); }; describe('xsrf post-auth handler', () => { @@ -142,6 +144,29 @@ describe('xsrf post-auth handler', () => { expect(toolkit.next).toHaveBeenCalledTimes(1); expect(result).toEqual('next'); }); + + it('accepts requests if xsrf protection on a route is disabled', () => { + const config = createConfig({ + xsrf: { whitelist: [], disableProtection: false }, + }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ + method: 'post', + headers: {}, + path: '/some-path', + kibanaRouteState: { + xsrfRequired: false, + }, + }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); }); }); diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts index ee877ee031a2b..7ef7e86326039 100644 --- a/src/core/server/http/lifecycle_handlers.ts +++ b/src/core/server/http/lifecycle_handlers.ts @@ -20,6 +20,7 @@ import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; import { HttpConfig } from './http_config'; +import { isSafeMethod } from './router'; import { Env } from '../config'; import { LifecycleRegistrar } from './http_server'; @@ -31,15 +32,18 @@ export const createXsrfPostAuthHandler = (config: HttpConfig): OnPostAuthHandler const { whitelist, disableProtection } = config.xsrf; return (request, response, toolkit) => { - if (disableProtection || whitelist.includes(request.route.path)) { + if ( + disableProtection || + whitelist.includes(request.route.path) || + request.route.options.xsrfRequired === false + ) { return toolkit.next(); } - const isSafeMethod = request.route.method === 'get' || request.route.method === 'head'; const hasVersionHeader = VERSION_HEADER in request.headers; const hasXsrfHeader = XSRF_HEADER in request.headers; - if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) { + if (!isSafeMethod(request.route.method) && !hasVersionHeader && !hasXsrfHeader) { return response.badRequest({ body: `Request must contain a ${XSRF_HEADER} header.` }); } diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index 32663d1513f36..d254f391ca5e4 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -24,16 +24,20 @@ export { KibanaRequestEvents, KibanaRequestRoute, KibanaRequestRouteOptions, + KibanaRouteState, isRealRequest, LegacyRequest, ensureRawRequest, } from './request'; export { + DestructiveRouteMethod, + isSafeMethod, RouteMethod, RouteConfig, RouteConfigOptions, RouteContentType, RouteConfigOptionsBody, + SafeRouteMethod, validBodyOutput, } from './route'; export { HapiResponseAdapter } from './response_adapter'; diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 703571ba53c0a..bb2db6367f701 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -18,18 +18,24 @@ */ import { Url } from 'url'; -import { Request } from 'hapi'; +import { Request, ApplicationState } from 'hapi'; import { Observable, fromEvent, merge } from 'rxjs'; import { shareReplay, first, takeUntil } from 'rxjs/operators'; import { deepFreeze, RecursiveReadonly } from '../../../utils'; import { Headers } from './headers'; -import { RouteMethod, RouteConfigOptions, validBodyOutput } from './route'; +import { RouteMethod, RouteConfigOptions, validBodyOutput, isSafeMethod } from './route'; import { KibanaSocket, IKibanaSocket } from './socket'; import { RouteValidator, RouteValidatorFullConfig } from './validator'; const requestSymbol = Symbol('request'); +/** + * @internal + */ +export interface KibanaRouteState extends ApplicationState { + xsrfRequired: boolean; +} /** * Route options: If 'GET' or 'OPTIONS' method, body options won't be returned. * @public @@ -184,8 +190,10 @@ export class KibanaRequest< const options = ({ authRequired: request.route.settings.auth !== false, + // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8 + xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true, tags: request.route.settings.tags || [], - body: ['get', 'options'].includes(method) + body: isSafeMethod(method) ? undefined : { parse, diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 4439a80b1eac7..d1458ef4ad063 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -19,11 +19,27 @@ import { RouteValidatorFullConfig } from './validator'; +export function isSafeMethod(method: RouteMethod): method is SafeRouteMethod { + return method === 'get' || method === 'options'; +} + +/** + * Set of HTTP methods changing the state of the server. + * @public + */ +export type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch'; + +/** + * Set of HTTP methods not changing the state of the server. + * @public + */ +export type SafeRouteMethod = 'get' | 'options'; + /** * The set of common HTTP methods supported by Kibana routing. * @public */ -export type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options'; +export type RouteMethod = SafeRouteMethod | DestructiveRouteMethod; /** * The set of valid body.output @@ -108,6 +124,15 @@ export interface RouteConfigOptions { */ authRequired?: boolean; + /** + * Defines xsrf protection requirements for a route: + * - true. Requires an incoming POST/PUT/DELETE request to contain `kbn-xsrf` header. + * - false. Disables xsrf protection. + * + * Set to true by default + */ + xsrfRequired?: Method extends 'get' ? never : boolean; + /** * Additional metadata tag strings to attach to the route. */ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index de6cdb2d7acd7..0c112e3cfb5b2 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -159,6 +159,8 @@ export { SessionStorageCookieOptions, SessionCookieValidationResult, SessionStorageFactory, + DestructiveRouteMethod, + SafeRouteMethod, } from './http'; export { RenderingServiceSetup, IRenderOptions } from './rendering'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 445ed16ec7829..8c5e84446a0d3 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -685,6 +685,9 @@ export interface DeprecationSettings { message: string; } +// @public +export type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch'; + // @public export interface DiscoveredPlugin { readonly configPath: ConfigPath; @@ -1459,6 +1462,7 @@ export interface RouteConfigOptions { authRequired?: boolean; body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; tags?: readonly string[]; + xsrfRequired?: Method extends 'get' ? never : boolean; } // @public @@ -1473,7 +1477,7 @@ export interface RouteConfigOptionsBody { export type RouteContentType = 'application/json' | 'application/*+json' | 'application/octet-stream' | 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/*'; // @public -export type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options'; +export type RouteMethod = SafeRouteMethod | DestructiveRouteMethod; // @public export type RouteRegistrar = (route: RouteConfig, handler: RequestHandler) => void; @@ -1526,6 +1530,9 @@ export interface RouteValidatorOptions { }; } +// @public +export type SafeRouteMethod = 'get' | 'options'; + // @public (undocumented) export interface SavedObject { attributes: T; diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js b/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js index ce42b26cc3e86..bc9cb15e1c5e0 100644 --- a/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js +++ b/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js @@ -5,11 +5,9 @@ */ let esBase = ''; -let xPackBase = ''; export function setEsBaseAndXPackBase(elasticWebsiteUrl, docLinksVersion) { esBase = `${elasticWebsiteUrl}guide/en/elasticsearch/reference/${docLinksVersion}`; - xPackBase = `${elasticWebsiteUrl}guide/en/x-pack/${docLinksVersion}`; } export const getLogisticalDetailsUrl = () => `${esBase}/rollup-job-config.html#_logistical_details`; @@ -21,4 +19,4 @@ export const getMetricsDetailsUrl = () => `${esBase}/rollup-job-config.html#roll export const getDateHistogramAggregationUrl = () => `${esBase}/search-aggregations-bucket-datehistogram-aggregation.html`; -export const getCronUrl = () => `${xPackBase}/trigger-schedule.html#_cron_expressions`; +export const getCronUrl = () => `${esBase}/trigger-schedule.html#_cron_expressions`; diff --git a/x-pack/legacy/plugins/transform/public/app/common/data_grid.ts b/x-pack/legacy/plugins/transform/public/app/common/data_grid.ts new file mode 100644 index 0000000000000..0783839afee83 --- /dev/null +++ b/x-pack/legacy/plugins/transform/public/app/common/data_grid.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiDataGridStyle } from '@elastic/eui'; + +export const euiDataGridStyle: EuiDataGridStyle = { + border: 'all', + fontSize: 's', + cellPadding: 's', + stripes: false, + rowHover: 'highlight', + header: 'shade', +}; + +export const euiDataGridToolbarSettings = { + showColumnSelector: true, + showStyleSelector: false, + showSortSelector: true, + showFullScreenSelector: false, +}; diff --git a/x-pack/legacy/plugins/transform/public/app/common/fields.ts b/x-pack/legacy/plugins/transform/public/app/common/fields.ts index f2181654286db..108f45ce67e37 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/fields.ts +++ b/x-pack/legacy/plugins/transform/public/app/common/fields.ts @@ -15,8 +15,6 @@ export interface EsDoc extends Dictionary { _source: EsDocSource; } -export const MAX_COLUMNS = 5; - export function getFlattenedFields(obj: EsDocSource): EsFieldName[] { const flatDocFields: EsFieldName[] = []; const newDocFields = Object.keys(obj); @@ -33,35 +31,33 @@ export function getFlattenedFields(obj: EsDocSource): EsFieldName[] { return flatDocFields; } -export const getSelectableFields = (docs: EsDoc[]): EsFieldName[] => { +export const getSelectableFields = (docs: EsDocSource[]): EsFieldName[] => { if (docs.length === 0) { return []; } - const newDocFields = getFlattenedFields(docs[0]._source); + const newDocFields = getFlattenedFields(docs[0]); newDocFields.sort(); return newDocFields; }; -export const getDefaultSelectableFields = (docs: EsDoc[]): EsFieldName[] => { +export const getDefaultSelectableFields = (docs: EsDocSource[]): EsFieldName[] => { if (docs.length === 0) { return []; } - const newDocFields = getFlattenedFields(docs[0]._source); + const newDocFields = getFlattenedFields(docs[0]); newDocFields.sort(); - return newDocFields - .filter(k => { - let value = false; - docs.forEach(row => { - const source = row._source; - if (source[k] !== null) { - value = true; - } - }); - return value; - }) - .slice(0, MAX_COLUMNS); + return newDocFields.filter(k => { + let value = false; + docs.forEach(row => { + const source = row; + if (source[k] !== null) { + value = true; + } + }); + return value; + }); }; export const toggleSelectedField = ( diff --git a/x-pack/legacy/plugins/transform/public/app/common/index.ts b/x-pack/legacy/plugins/transform/public/app/common/index.ts index 3f515db389b45..52a6884367bc5 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/index.ts +++ b/x-pack/legacy/plugins/transform/public/app/common/index.ts @@ -5,6 +5,7 @@ */ export { AggName, isAggName } from './aggregations'; +export { euiDataGridStyle, euiDataGridToolbarSettings } from './data_grid'; export { getDefaultSelectableFields, getFlattenedFields, @@ -13,7 +14,6 @@ export { EsDoc, EsDocSource, EsFieldName, - MAX_COLUMNS, } from './fields'; export { DropDownLabel, DropDownOption, Label } from './dropdown'; export { diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx index 3268d6697ed44..76ed12ff772f5 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx @@ -4,59 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; -import moment from 'moment-timezone'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { - EuiBadge, - EuiButtonEmpty, EuiButtonIcon, EuiCallOut, - EuiCheckbox, EuiCodeBlock, EuiCopy, + EuiDataGrid, EuiFlexGroup, EuiFlexItem, - EuiPanel, - EuiPopover, - EuiPopoverTitle, EuiProgress, - EuiText, EuiTitle, - EuiToolTip, - RIGHT_ALIGNMENT, } from '@elastic/eui'; -import { - ColumnType, - mlInMemoryTableBasicFactory, - SortingPropType, - SORT_DIRECTION, -} from '../../../../../shared_imports'; - -import { KBN_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; -import { Dictionary } from '../../../../../../common/types/common'; -import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/utils/date_utils'; +import { getNestedProperty } from '../../../../../../common/utils/object_utils'; import { - toggleSelectedField, - EsDoc, + euiDataGridStyle, + euiDataGridToolbarSettings, EsFieldName, - MAX_COLUMNS, PivotQuery, } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; import { getSourceIndexDevConsoleStatement } from './common'; -import { ExpandedRow } from './expanded_row'; import { SOURCE_INDEX_STATUS, useSourceIndexData } from './use_source_index_data'; -type ItemIdToExpandedRowMap = Dictionary; - -const CELL_CLICK_ENABLED = false; - interface SourceIndexPreviewTitle { indexPatternTitle: string; } @@ -74,360 +50,196 @@ const SourceIndexPreviewTitle: React.FC = ({ indexPatte interface Props { indexPattern: SearchItems['indexPattern']; query: PivotQuery; - cellClick?(search: string): void; } -export const SourceIndexPreview: React.FC = React.memo( - ({ indexPattern, cellClick, query }) => { - const [clearTable, setClearTable] = useState(false); - - const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); - const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); - - // EuiInMemoryTable has an issue with dynamic sortable columns - // and will trigger a full page Kibana error in such a case. - // The following is a workaround until this is solved upstream: - // - If the sortable/columns config changes, - // the table will be unmounted/not rendered. - // This is what setClearTable(true) in toggleColumn() does. - // - After that on next render it gets re-enabled. To make sure React - // doesn't consolidate the state updates, setTimeout is used. - if (clearTable) { - setTimeout(() => setClearTable(false), 0); - } +const defaultPagination = { pageIndex: 0, pageSize: 5 }; - function toggleColumnsPopover() { - setColumnsPopoverVisible(!isColumnsPopoverVisible); +export const SourceIndexPreview: React.FC = React.memo(({ indexPattern, query }) => { + const allFields = indexPattern.fields.map(f => f.name); + const indexPatternFields: string[] = allFields.filter(f => { + if (indexPattern.metaFields.includes(f)) { + return false; } - function closeColumnsPopover() { - setColumnsPopoverVisible(false); + const fieldParts = f.split('.'); + const lastPart = fieldParts.pop(); + if (lastPart === 'keyword' && allFields.includes(fieldParts.join('.'))) { + return false; } - function toggleColumn(column: EsFieldName) { - // spread to a new array otherwise the component wouldn't re-render - setClearTable(true); - setSelectedFields([...toggleSelectedField(selectedFields, column)]); - } + return true; + }); - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState( - {} as ItemIdToExpandedRowMap - ); + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(indexPatternFields); - function toggleDetails(item: EsDoc) { - if (itemIdToExpandedRowMap[item._id]) { - delete itemIdToExpandedRowMap[item._id]; - } else { - itemIdToExpandedRowMap[item._id] = ; - } - // spread to a new object otherwise the component wouldn't re-render - setItemIdToExpandedRowMap({ ...itemIdToExpandedRowMap }); - } + const [pagination, setPagination] = useState(defaultPagination); - const { errorMessage, status, tableItems } = useSourceIndexData( - indexPattern, - query, - selectedFields, - setSelectedFields - ); + useEffect(() => { + setPagination(defaultPagination); + }, [query]); - if (status === SOURCE_INDEX_STATUS.ERROR) { - return ( - - - - - {errorMessage} - - - - ); - } + const { errorMessage, status, rowCount, tableItems: data } = useSourceIndexData( + indexPattern, + query, + pagination + ); - if (status === SOURCE_INDEX_STATUS.LOADED && tableItems.length === 0) { - return ( - - - -

- {i18n.translate('xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutBody', { - defaultMessage: - 'The query for the source index returned no results. Please make sure you have sufficient permissions, the index contains documents and your query is not too restrictive.', - })} -

- - - ); - } + // EuiDataGrid State + const dataGridColumns = indexPatternFields.map(id => { + const field = indexPattern.fields.getByName(id); - let docFields: EsFieldName[] = []; - let docFieldsCount = 0; - if (tableItems.length > 0) { - docFields = Object.keys(tableItems[0]._source); - docFields.sort(); - docFieldsCount = docFields.length; + let schema = 'string'; + + switch (field?.type) { + case 'date': + schema = 'datetime'; + break; + case 'geo_point': + schema = 'json'; + break; + case 'number': + schema = 'numeric'; + break; } - const columns: Array> = selectedFields.map(k => { - const column: ColumnType = { - field: `_source["${k}"]`, - name: k, - sortable: true, - truncateText: true, - }; - - const field = indexPattern.fields.find(f => f.name === k); - - const formatField = (d: string) => { - return field !== undefined && field.type === KBN_FIELD_TYPES.DATE - ? formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000) - : d; - }; - - const render = (d: any) => { - if (Array.isArray(d) && d.every(item => typeof item === 'string')) { - // If the cells data is an array of strings, return as a comma separated list. - // The list will get limited to 5 items with `…` at the end if there's more in the original array. - return `${d - .map(item => formatField(item)) - .slice(0, 5) - .join(', ')}${d.length > 5 ? ', …' : ''}`; - } else if (Array.isArray(d)) { - // If the cells data is an array of e.g. objects, display a 'array' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate('xpack.transform.sourceIndexPreview.SourceIndexArrayBadgeContent', { - defaultMessage: 'array', - })} - - - ); - } else if (typeof d === 'object' && d !== null) { - // If the cells data is an object, display a 'object' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.transform.sourceIndexPreview.SourceIndexObjectBadgeContent', - { - defaultMessage: 'object', - } - )} - - - ); - } - - return formatField(d); - }; - - if (typeof field !== 'undefined') { - switch (field.type) { - case KBN_FIELD_TYPES.BOOLEAN: - column.dataType = 'boolean'; - break; - case KBN_FIELD_TYPES.DATE: - column.align = 'right'; - column.render = (d: any) => formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - break; - case KBN_FIELD_TYPES.NUMBER: - column.dataType = 'number'; - break; - default: - column.render = render; - break; - } - } else { - column.render = render; + return { id, schema }; + }); + + const onChangeItemsPerPage = useCallback( + pageSize => { + setPagination(p => { + const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); + return { pageIndex, pageSize }; + }); + }, + [setPagination] + ); + + const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ + setPagination, + ]); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); + + const renderCellValue = useMemo(() => { + return ({ + rowIndex, + columnId, + setCellProps, + }: { + rowIndex: number; + columnId: string; + setCellProps: any; + }) => { + const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + const cellValue = data.hasOwnProperty(adjustedRowIndex) + ? getNestedProperty(data[adjustedRowIndex], columnId, null) + : null; + + if (typeof cellValue === 'object' && cellValue !== null) { + return JSON.stringify(cellValue); } - if (CELL_CLICK_ENABLED && cellClick) { - column.render = (d: string) => ( - cellClick(`${k}:(${d})`)}> - {render(d)} - - ); + if (cellValue === undefined) { + return null; } - return column; - }); - - let sorting: SortingPropType = false; + return cellValue; + }; + }, [data, pagination.pageIndex, pagination.pageSize]); - if (columns.length > 0) { - sorting = { - sort: { - field: `_source["${selectedFields[0]}"]`, - direction: SORT_DIRECTION.ASC, - }, - }; - } - - columns.unshift({ - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - render: (item: EsDoc) => ( - toggleDetails(item)} - aria-label={ - itemIdToExpandedRowMap[item._id] - ? i18n.translate('xpack.transform.sourceIndexPreview.rowCollapse', { - defaultMessage: 'Collapse', - }) - : i18n.translate('xpack.transform.sourceIndexPreview.rowExpand', { - defaultMessage: 'Expand', - }) - } - iconType={itemIdToExpandedRowMap[item._id] ? 'arrowUp' : 'arrowDown'} - /> - ), - }); + if (status === SOURCE_INDEX_STATUS.ERROR) { + return ( +
+ + + + {errorMessage} + + +
+ ); + } - const euiCopyText = i18n.translate('xpack.transform.sourceIndexPreview.copyClipboardTooltip', { - defaultMessage: 'Copy Dev Console statement of the source index preview to the clipboard.', - }); + if (status === SOURCE_INDEX_STATUS.LOADED && data.length === 0) { + return ( +
+ + +

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

+
+
+ ); + } - const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); + const euiCopyText = i18n.translate('xpack.transform.sourceIndexPreview.copyClipboardTooltip', { + defaultMessage: 'Copy Dev Console statement of the source index preview to the clipboard.', + }); - return ( - - - - - - - - - {docFieldsCount > MAX_COLUMNS && ( - - {i18n.translate('xpack.transform.sourceIndexPreview.fieldSelection', { - defaultMessage: - '{selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}} selected', - values: { selectedFieldsLength: selectedFields.length, docFieldsCount }, - })} - - )} - - - - - } - isOpen={isColumnsPopoverVisible} - closePopover={closeColumnsPopover} - ownFocus - > - - {i18n.translate( - 'xpack.transform.sourceIndexPreview.selectFieldsPopoverTitle', - { - defaultMessage: 'Select fields', - } - )} - -
- {docFields.map(d => ( - toggleColumn(d)} - disabled={selectedFields.includes(d) && selectedFields.length === 1} - /> - ))} -
-
-
-
- - - {(copy: () => void) => ( - - )} - - -
-
-
+ return ( +
+ + + + + + + {(copy: () => void) => ( + + )} + + + +
{status === SOURCE_INDEX_STATUS.LOADING && } {status !== SOURCE_INDEX_STATUS.LOADING && ( )} - {clearTable === false && columns.length > 0 && sorting !== false && ( - ({ - 'data-test-subj': `transformSourceIndexPreviewRow row-${item._id}`, - })} - sorting={sorting} - /> - )} - - ); - } -); +
+ {dataGridColumns.length > 0 && data.length > 0 && ( + + )} +
+ ); +}); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx index fb0a71baea321..715573e3a6f67 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx @@ -51,8 +51,7 @@ describe('useSourceIndexData', () => { sourceIndexObj = useSourceIndexData( { id: 'the-id', title: 'the-title', fields: [] }, query, - [], - () => {} + { pageIndex: 0, pageSize: 10 } ); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts index e5c6783db1022..ae5bd9040baca 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts @@ -4,27 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { SearchResponse } from 'elasticsearch'; import { IIndexPattern } from 'src/plugins/data/public'; import { useApi } from '../../../../hooks/use_api'; -import { getNestedProperty } from '../../../../../../common/utils/object_utils'; -import { - getDefaultSelectableFields, - getFlattenedFields, - isDefaultQuery, - matchAllQuery, - EsDoc, - EsDocSource, - EsFieldName, - PivotQuery, -} from '../../../../common'; - -const SEARCH_SIZE = 1000; +import { isDefaultQuery, matchAllQuery, EsDocSource, PivotQuery } from '../../../../common'; export enum SOURCE_INDEX_STATUS { UNUSED, @@ -48,23 +36,34 @@ const isErrorResponse = (arg: any): arg is ErrorResponse => { return arg.error !== undefined; }; -type SourceIndexSearchResponse = ErrorResponse | SearchResponse; +// The types specified in `@types/elasticsearch` are out of date and still have `total: number`. +interface SearchResponse7 extends SearchResponse { + hits: SearchResponse['hits'] & { + total: { + value: number; + relation: string; + }; + }; +} + +type SourceIndexSearchResponse = ErrorResponse | SearchResponse7; export interface UseSourceIndexDataReturnType { errorMessage: string; status: SOURCE_INDEX_STATUS; - tableItems: EsDoc[]; + rowCount: number; + tableItems: EsDocSource[]; } export const useSourceIndexData = ( indexPattern: IIndexPattern, query: PivotQuery, - selectedFields: EsFieldName[], - setSelectedFields: React.Dispatch> + pagination: { pageIndex: number; pageSize: number } ): UseSourceIndexDataReturnType => { const [errorMessage, setErrorMessage] = useState(''); const [status, setStatus] = useState(SOURCE_INDEX_STATUS.UNUSED); - const [tableItems, setTableItems] = useState([]); + const [rowCount, setRowCount] = useState(0); + const [tableItems, setTableItems] = useState([]); const api = useApi(); const getSourceIndexData = async function() { @@ -74,7 +73,8 @@ export const useSourceIndexData = ( try { const resp: SourceIndexSearchResponse = await api.esSearch({ index: indexPattern.title, - size: SEARCH_SIZE, + from: pagination.pageIndex * pagination.pageSize, + size: pagination.pageSize, // Instead of using the default query (`*`), fall back to a more efficient `match_all` query. body: { query: isDefaultQuery(query) ? matchAllQuery : query }, }); @@ -83,41 +83,10 @@ export const useSourceIndexData = ( throw resp.error; } - const docs = resp.hits.hits; - - if (docs.length === 0) { - setTableItems([]); - setStatus(SOURCE_INDEX_STATUS.LOADED); - return; - } - - if (selectedFields.length === 0) { - const newSelectedFields = getDefaultSelectableFields(docs); - setSelectedFields(newSelectedFields); - } - - // Create a version of the doc's source with flattened field names. - // This avoids confusion later on if a field name has dots in its name - // or is a nested fields when displaying it via EuiInMemoryTable. - const flattenedFields = getFlattenedFields(docs[0]._source); - const transformedTableItems = docs.map(doc => { - const item: EsDocSource = {}; - flattenedFields.forEach(ff => { - item[ff] = getNestedProperty(doc._source, ff); - if (item[ff] === undefined) { - // If the attribute is undefined, it means it was not a nested property - // but had dots in its actual name. This selects the property by its - // full name and assigns it to `item[ff]`. - item[ff] = doc._source[`"${ff}"`]; - } - }); - return { - ...doc, - _source: item, - }; - }); + const docs = resp.hits.hits.map(d => d._source); - setTableItems(transformedTableItems); + setRowCount(resp.hits.total.value); + setTableItems(docs); setStatus(SOURCE_INDEX_STATUS.LOADED); } catch (e) { if (e.message !== undefined) { @@ -134,6 +103,6 @@ export const useSourceIndexData = ( getSourceIndexData(); // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps - }, [indexPattern.title, JSON.stringify(query)]); - return { errorMessage, status, tableItems }; + }, [indexPattern.title, JSON.stringify(query), JSON.stringify(pagination)]); + return { errorMessage, status, rowCount, tableItems }; }; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index b61045e9b5c77..4198c2ea0260d 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -66,6 +66,7 @@ export const StepCreateForm: FC = React.memo( const [redirectToTransformManagement, setRedirectToTransformManagement] = useState(false); + const [loading, setLoading] = useState(false); const [created, setCreated] = useState(defaults.created); const [started, setStarted] = useState(defaults.started); const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId); @@ -87,7 +88,7 @@ export const StepCreateForm: FC = React.memo( const api = useApi(); async function createTransform() { - setCreated(true); + setLoading(true); try { const resp = await api.createTransform(transformId, transformConfig); @@ -107,8 +108,9 @@ export const StepCreateForm: FC = React.memo( values: { transformId }, }) ); + setCreated(true); + setLoading(false); } catch (e) { - setCreated(false); toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepCreateForm.createTransformErrorMessage', { defaultMessage: 'An error occurred creating the transform {transformId}:', @@ -116,6 +118,8 @@ export const StepCreateForm: FC = React.memo( }), text: toMountPoint(), }); + setCreated(false); + setLoading(false); return false; } @@ -127,18 +131,27 @@ export const StepCreateForm: FC = React.memo( } async function startTransform() { - setStarted(true); + setLoading(true); try { - await api.startTransforms([{ id: transformId }]); - toastNotifications.addSuccess( - i18n.translate('xpack.transform.stepCreateForm.startTransformSuccessMessage', { - defaultMessage: 'Request to start transform {transformId} acknowledged.', - values: { transformId }, - }) - ); + const resp = await api.startTransforms([{ id: transformId }]); + if (typeof resp === 'object' && resp !== null && resp[transformId]?.success === true) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.stepCreateForm.startTransformSuccessMessage', { + defaultMessage: 'Request to start transform {transformId} acknowledged.', + values: { transformId }, + }) + ); + setStarted(true); + setLoading(false); + } else { + const errorMessage = + typeof resp === 'object' && resp !== null && resp[transformId]?.success === false + ? resp[transformId].error + : resp; + throw new Error(errorMessage); + } } catch (e) { - setStarted(false); toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepCreateForm.startTransformErrorMessage', { defaultMessage: 'An error occurred starting the transform {transformId}:', @@ -146,6 +159,8 @@ export const StepCreateForm: FC = React.memo( }), text: toMountPoint(), }); + setStarted(false); + setLoading(false); } } @@ -157,6 +172,7 @@ export const StepCreateForm: FC = React.memo( } const createKibanaIndexPattern = async () => { + setLoading(true); const indexPatternName = transformConfig.dest.index; try { @@ -178,6 +194,7 @@ export const StepCreateForm: FC = React.memo( values: { indexPatternName }, }) ); + setLoading(false); return; } @@ -195,6 +212,7 @@ export const StepCreateForm: FC = React.memo( ); setIndexPatternId(id); + setLoading(false); return true; } catch (e) { toastNotifications.addDanger({ @@ -205,13 +223,19 @@ export const StepCreateForm: FC = React.memo( }), text: toMountPoint(), }); + setLoading(false); return false; } }; const isBatchTransform = typeof transformConfig.sync === 'undefined'; - if (started === true && progressPercentComplete === undefined && isBatchTransform) { + if ( + loading === false && + started === true && + progressPercentComplete === undefined && + isBatchTransform + ) { // wrapping in function so we can keep the interval id in local scope function startProgressBar() { const interval = setInterval(async () => { @@ -266,7 +290,7 @@ export const StepCreateForm: FC = React.memo( @@ -293,7 +317,7 @@ export const StepCreateForm: FC = React.memo( @@ -315,7 +339,7 @@ export const StepCreateForm: FC = React.memo( diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts index 78ad217a69e3d..88e009c63339a 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiDataGridSorting } from '@elastic/eui'; + import { getPreviewRequestBody, PivotAggsConfig, @@ -13,10 +15,62 @@ import { SimpleQuery, } from '../../../../common'; -import { getPivotPreviewDevConsoleStatement, getPivotDropdownOptions } from './common'; +import { + multiColumnSortFactory, + getPivotPreviewDevConsoleStatement, + getPivotDropdownOptions, +} from './common'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; describe('Transform: Define Pivot Common', () => { + test('customSortFactory()', () => { + const data = [ + { s: 'a', n: 1 }, + { s: 'a', n: 2 }, + { s: 'b', n: 3 }, + { s: 'b', n: 4 }, + ]; + + const sortingColumns1: EuiDataGridSorting['columns'] = [{ id: 's', direction: 'desc' }]; + const multiColumnSort1 = multiColumnSortFactory(sortingColumns1); + data.sort(multiColumnSort1); + + expect(data).toStrictEqual([ + { s: 'b', n: 3 }, + { s: 'b', n: 4 }, + { s: 'a', n: 1 }, + { s: 'a', n: 2 }, + ]); + + const sortingColumns2: EuiDataGridSorting['columns'] = [ + { id: 's', direction: 'asc' }, + { id: 'n', direction: 'desc' }, + ]; + const multiColumnSort2 = multiColumnSortFactory(sortingColumns2); + data.sort(multiColumnSort2); + + expect(data).toStrictEqual([ + { s: 'a', n: 2 }, + { s: 'a', n: 1 }, + { s: 'b', n: 4 }, + { s: 'b', n: 3 }, + ]); + + const sortingColumns3: EuiDataGridSorting['columns'] = [ + { id: 'n', direction: 'desc' }, + { id: 's', direction: 'desc' }, + ]; + const multiColumnSort3 = multiColumnSortFactory(sortingColumns3); + data.sort(multiColumnSort3); + + expect(data).toStrictEqual([ + { s: 'b', n: 4 }, + { s: 'b', n: 3 }, + { s: 'a', n: 2 }, + { s: 'a', n: 1 }, + ]); + }); + test('getPivotDropdownOptions()', () => { // The field name includes the characters []> as well as a leading and ending space charcter // which cannot be used for aggregation names. The test results verifies that the characters diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts index b4b03c1f0d571..7b78d4ffccfa1 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ import { get } from 'lodash'; -import { EuiComboBoxOptionProps } from '@elastic/eui'; +import { EuiComboBoxOptionProps, EuiDataGridSorting } from '@elastic/eui'; import { IndexPattern, KBN_FIELD_TYPES, } from '../../../../../../../../../../src/plugins/data/public'; +import { getNestedProperty } from '../../../../../../common/utils/object_utils'; + import { PreviewRequestBody, DropDownLabel, @@ -28,6 +30,51 @@ export interface Field { type: KBN_FIELD_TYPES; } +/** + * Helper to sort an array of objects based on an EuiDataGrid sorting configuration. + * `sortFn()` is recursive to support sorting on multiple columns. + * + * @param sortingColumns - The EUI data grid sorting configuration + * @returns The sorting function which can be used with an array's sort() function. + */ +export const multiColumnSortFactory = (sortingColumns: EuiDataGridSorting['columns']) => { + const isString = (arg: any): arg is string => { + return typeof arg === 'string'; + }; + + const sortFn = (a: any, b: any, sortingColumnIndex = 0): number => { + const sort = sortingColumns[sortingColumnIndex]; + const aValue = getNestedProperty(a, sort.id, null); + const bValue = getNestedProperty(b, sort.id, null); + + if (typeof aValue === 'number' && typeof bValue === 'number') { + if (aValue < bValue) { + return sort.direction === 'asc' ? -1 : 1; + } + if (aValue > bValue) { + return sort.direction === 'asc' ? 1 : -1; + } + } + + if (isString(aValue) && isString(bValue)) { + if (aValue.localeCompare(bValue) === -1) { + return sort.direction === 'asc' ? -1 : 1; + } + if (aValue.localeCompare(bValue) === 1) { + return sort.direction === 'asc' ? 1 : -1; + } + } + + if (sortingColumnIndex + 1 < sortingColumns.length) { + return sortFn(a, b, sortingColumnIndex + 1); + } + + return 0; + }; + + return sortFn; +}; + function getDefaultGroupByConfig( aggName: string, dropDownName: string, diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx index 1932e7dbe219f..9b32bbbae839e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useEffect, useRef, useState } from 'react'; -import moment from 'moment-timezone'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; @@ -14,24 +13,21 @@ import { EuiCallOut, EuiCodeBlock, EuiCopy, + EuiDataGrid, + EuiDataGridSorting, EuiFlexGroup, EuiFlexItem, - EuiPanel, EuiProgress, EuiTitle, } from '@elastic/eui'; -import { - ColumnType, - mlInMemoryTableBasicFactory, - SORT_DIRECTION, -} from '../../../../../shared_imports'; import { dictionaryToArray } from '../../../../../../common/types/common'; -import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; -import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/utils/date_utils'; +import { getNestedProperty } from '../../../../../../common/utils/object_utils'; import { - getFlattenedFields, + euiDataGridStyle, + euiDataGridToolbarSettings, + EsFieldName, PreviewRequestBody, PivotAggsConfigDict, PivotGroupByConfig, @@ -40,8 +36,8 @@ import { } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; -import { getPivotPreviewDevConsoleStatement } from './common'; -import { PreviewItem, PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data'; +import { getPivotPreviewDevConsoleStatement, multiColumnSortFactory } from './common'; +import { PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data'; function sortColumns(groupByArr: PivotGroupByConfig[]) { return (a: string, b: string) => { @@ -59,14 +55,6 @@ function sortColumns(groupByArr: PivotGroupByConfig[]) { }; } -function usePrevious(value: any) { - const ref = useRef(null); - useEffect(() => { - ref.current = value; - }); - return ref.current; -} - interface PreviewTitleProps { previewRequest: PreviewRequestBody; } @@ -118,50 +106,102 @@ interface PivotPreviewProps { query: PivotQuery; } +const defaultPagination = { pageIndex: 0, pageSize: 5 }; + export const PivotPreview: FC = React.memo( ({ aggs, groupBy, indexPattern, query }) => { - const [clearTable, setClearTable] = useState(false); - const { - previewData, + previewData: data, previewMappings, errorMessage, previewRequest, status, } = usePivotPreviewData(indexPattern, query, aggs, groupBy); - const groupByArr = dictionaryToArray(groupBy); - // EuiInMemoryTable has an issue with dynamic sortable columns - // and will trigger a full page Kibana error in such a case. - // The following is a workaround until this is solved upstream: - // - If the sortable/columns config changes, - // the table will be unmounted/not rendered. - // This is what the useEffect() part does. - // - After that the table gets re-enabled. To make sure React - // doesn't consolidate the state updates, setTimeout is used. - const firstColumnName = - previewData.length > 0 - ? Object.keys(previewData[0]).sort(sortColumns(groupByArr))[0] - : undefined; - - const firstColumnNameChanged = usePrevious(firstColumnName) !== firstColumnName; + // Filters mapping properties of type `object`, which get returned for nested field parents. + const columnKeys = Object.keys(previewMappings.properties).filter( + key => previewMappings.properties[key].type !== 'object' + ); + columnKeys.sort(sortColumns(groupByArr)); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(columnKeys); + useEffect(() => { - if (firstColumnNameChanged) { - setClearTable(true); - } - if (clearTable) { - setTimeout(() => setClearTable(false), 0); - } - }, [firstColumnNameChanged, clearTable]); + setVisibleColumns(columnKeys); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(columnKeys)]); - if (firstColumnNameChanged) { - return null; + const [pagination, setPagination] = useState(defaultPagination); + + // Reset pagination if data changes. This is to avoid ending up with an empty table + // when for example the user selected a page that is not available with the updated data. + useEffect(() => { + setPagination(defaultPagination); + }, [data.length]); + + // EuiDataGrid State + const dataGridColumns = columnKeys.map(id => ({ id })); + + const onChangeItemsPerPage = useCallback( + pageSize => { + setPagination(p => { + const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); + return { pageIndex, pageSize }; + }); + }, + [setPagination] + ); + + const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ + setPagination, + ]); + + // Sorting config + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); + + if (sortingColumns.length > 0) { + data.sort(multiColumnSortFactory(sortingColumns)); } + const pageData = data.slice( + pagination.pageIndex * pagination.pageSize, + (pagination.pageIndex + 1) * pagination.pageSize + ); + + const renderCellValue = useMemo(() => { + return ({ + rowIndex, + columnId, + setCellProps, + }: { + rowIndex: number; + columnId: string; + setCellProps: any; + }) => { + const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + const cellValue = pageData.hasOwnProperty(adjustedRowIndex) + ? getNestedProperty(pageData[adjustedRowIndex], columnId, null) + : null; + + if (typeof cellValue === 'object' && cellValue !== null) { + return JSON.stringify(cellValue); + } + + if (cellValue === undefined) { + return null; + } + + return cellValue; + }; + }, [pageData, pagination.pageIndex, pagination.pageSize]); + if (status === PIVOT_PREVIEW_STATUS.ERROR) { return ( - +
= React.memo( > - +
); } - if (previewData.length === 0) { + if (data.length === 0) { let noDataMessage = i18n.translate( 'xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody', { @@ -194,8 +234,9 @@ export const PivotPreview: FC = React.memo( } ); } + return ( - +
= React.memo( >

{noDataMessage}

- +
); } - const columnKeys = getFlattenedFields(previewData[0]); - columnKeys.sort(sortColumns(groupByArr)); - - const columns = columnKeys.map(k => { - const column: ColumnType = { - field: k, - name: k, - sortable: true, - truncateText: true, - }; - if (typeof previewMappings.properties[k] !== 'undefined') { - const esFieldType = previewMappings.properties[k].type; - switch (esFieldType) { - case ES_FIELD_TYPES.BOOLEAN: - column.dataType = 'boolean'; - break; - case ES_FIELD_TYPES.DATE: - column.align = 'right'; - column.render = (d: any) => formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - break; - case ES_FIELD_TYPES.BYTE: - case ES_FIELD_TYPES.DOUBLE: - case ES_FIELD_TYPES.FLOAT: - case ES_FIELD_TYPES.HALF_FLOAT: - case ES_FIELD_TYPES.INTEGER: - case ES_FIELD_TYPES.LONG: - case ES_FIELD_TYPES.SCALED_FLOAT: - case ES_FIELD_TYPES.SHORT: - column.dataType = 'number'; - break; - case ES_FIELD_TYPES.KEYWORD: - case ES_FIELD_TYPES.TEXT: - column.dataType = 'string'; - break; - } - } - return column; - }); - - if (columns.length === 0) { + if (columnKeys.length === 0) { return null; } - const sorting = { - sort: { - field: columns[0].field as string, - direction: SORT_DIRECTION.ASC, - }, - }; - - const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); - return ( - +
- {status === PIVOT_PREVIEW_STATUS.LOADING && } - {status !== PIVOT_PREVIEW_STATUS.LOADING && ( - - )} - {previewData.length > 0 && clearTable === false && columns.length > 0 && ( - + {status === PIVOT_PREVIEW_STATUS.LOADING && } + {status !== PIVOT_PREVIEW_STATUS.LOADING && ( + + )} +
+ {dataGridColumns.length > 0 && data.length > 0 && ( + ({ - 'data-test-subj': 'transformPivotPreviewRow', - })} - sorting={sorting} /> )} -
+ ); } ); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index b5333e69001d7..f61f54c38680e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -17,6 +17,7 @@ import { EuiForm, EuiFormHelpText, EuiFormRow, + EuiHorizontalRule, EuiLink, EuiPanel, // @ts-ignore @@ -246,11 +247,6 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, const [searchQuery, setSearchQuery] = useState(defaults.searchQuery); const [useKQL] = useState(true); - const addToSearch = (newSearch: string) => { - const currentDisplaySearch = searchString === defaultSearch ? emptySearch : searchString; - setSearchString(`${currentDisplaySearch} ${newSearch}`.trim()); - }; - const searchHandler = (d: Record) => { const { filterQuery, queryString } = d; const newSearch = queryString === emptySearch ? defaultSearch : queryString; @@ -559,8 +555,8 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, const disabledQuery = numIndexFields > maxIndexFields; return ( - - + +
{searchItems.savedSearch === undefined && typeof searchString === 'string' && ( @@ -896,13 +892,9 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange,
- - - + + + ({ - text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { - defaultMessage: 'Uptime', - }), - href: `#/${search ? search : ''}`, -}); - -export const getOverviewPageBreadcrumbs = (search?: string): ChromeBreadcrumb[] => [ - makeOverviewBreadcrumb(search), -]; - -export const getMonitorPageBreadcrumb = (name: string, search?: string): ChromeBreadcrumb[] => [ - makeOverviewBreadcrumb(search), - { text: name }, -]; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx deleted file mode 100644 index 9429b87061ff7..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx +++ /dev/null @@ -1,16 +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 { connect } from 'react-redux'; -import { selectSelectedMonitor } from '../../../state/selectors'; -import { AppState } from '../../../state'; -import { PageHeaderComponent } from '../../../pages/page_header'; - -const mapStateToProps = (state: AppState) => ({ - monitorStatus: selectSelectedMonitor(state), -}); - -export const PageHeader = connect(mapStateToProps, null)(PageHeaderComponent); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap new file mode 100644 index 0000000000000..79ef7b3b97abd --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChartEmptyState renders JSX values 1`] = ` + + + down + , + } + } + /> +

+ } + title={ + +
+ +
+
+ } +/> +`; + +exports[`ChartEmptyState renders string values 1`] = ` + + This is the body +

+ } + title={ + +
+ This is the title +
+
+ } +/> +`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx new file mode 100644 index 0000000000000..2e25dddc0b4ed --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ChartEmptyState } from '../chart_empty_state'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +describe('ChartEmptyState', () => { + it('renders string values', () => { + expect( + shallowWithIntl() + ).toMatchSnapshot(); + }); + + it('renders JSX values', () => { + expect( + shallowWithIntl( + down }} + /> + } + title={} + /> + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx new file mode 100644 index 0000000000000..19202822fe737 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiEmptyPrompt, EuiTitle } from '@elastic/eui'; +import React, { FC } from 'react'; + +interface ChartEmptyStateProps { + title: string | JSX.Element; + body: string | JSX.Element; +} + +export const ChartEmptyState: FC = ({ title, body }) => ( + +
{title}
+ + } + body={

{body}

} + /> +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx deleted file mode 100644 index a88a9668660f7..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx +++ /dev/null @@ -1,124 +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 { - AreaSeries, - Axis, - Chart, - Position, - Settings, - ScaleType, - timeFormatter, -} from '@elastic/charts'; -import { EuiPanel, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { StatusData } from '../../../../common/graphql/types'; -import { getChartDateLabel } from '../../../lib/helper'; -import { useUrlParams } from '../../../hooks'; - -interface ChecksChartProps { - /** - * The color that will be used for the area series displaying "Down" checks. - */ - dangerColor: string; - /** - * The timeseries data displayed in the chart. - */ - status: StatusData[]; - /** - * The color that will be used for the area series displaying "Up" checks. - */ - successColor: string; -} - -/** - * Renders a chart that displays the total count of up/down status checks over time - * as a stacked area chart. - * @param props The props values required by this component. - */ -export const ChecksChart = ({ dangerColor, status, successColor }: ChecksChartProps) => { - const upSeriesSpecId = 'Up'; - const downSeriesSpecId = 'Down'; - const [getUrlParams] = useUrlParams(); - const { absoluteDateRangeStart: min, absoluteDateRangeEnd: max } = getUrlParams(); - - const upString = i18n.translate('xpack.uptime.monitorCharts.checkStatus.series.upCountLabel', { - defaultMessage: 'Up count', - }); - const downString = i18n.translate( - 'xpack.uptime.monitorCharts.checkStatus.series.downCountLabel', - { - defaultMessage: 'Down count', - } - ); - - return ( - - -

- -

-
- - - - - Number(d).toFixed(0)} - title={i18n.translate('xpack.uptime.monitorChart.checksChart.leftAxis.title', { - defaultMessage: 'Number of checks', - description: 'The heading of the y-axis of a chart of timeseries data', - })} - /> - ({ - x, - [upString]: up || 0, - }))} - id={upSeriesSpecId} - stackAccessors={['x']} - timeZone="local" - xAccessor="x" - xScaleType={ScaleType.Time} - yAccessors={[upString]} - yScaleType={ScaleType.Linear} - /> - ({ - x, - [downString]: down || 0, - }))} - id={downSeriesSpecId} - stackAccessors={['x']} - timeZone="local" - xAccessor="x" - xScaleType={ScaleType.Time} - yAccessors={[downString]} - yScaleType={ScaleType.Linear} - /> - - -
- ); -}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx index 7a6db6d952dd9..0488e2531bc98 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx @@ -13,10 +13,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { getChartDateLabel } from '../../../lib/helper'; import { LocationDurationLine } from '../../../../common/graphql/types'; import { DurationLineSeriesList } from './duration_line_series_list'; -import { DurationChartEmptyState } from './duration_chart_empty_state'; import { ChartWrapper } from './chart_wrapper'; import { useUrlParams } from '../../../hooks'; import { getTickFormat } from './get_tick_format'; +import { ChartEmptyState } from './chart_empty_state'; interface DurationChartProps { /** @@ -102,7 +102,18 @@ export const DurationChart = ({ ) : ( - + up }} + /> + } + title={i18n.translate('xpack.uptime.durationChart.emptyPrompt.title', { + defaultMessage: 'No duration data available', + })} + /> )}
diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx deleted file mode 100644 index ef4e70bf65898..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiEmptyPrompt, EuiTitle } from '@elastic/eui'; -import React from 'react'; - -export const DurationChartEmptyState = () => ( - -
- -
- - } - body={ -

- up }} - /> -

- } - /> -); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx index b4989282f854c..6119d897cbf53 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx @@ -5,7 +5,7 @@ */ import { Axis, BarSeries, Chart, Position, Settings, timeFormatter } from '@elastic/charts'; -import { EuiEmptyPrompt, EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -15,6 +15,7 @@ import { ChartWrapper } from './chart_wrapper'; import { UptimeThemeContext } from '../../../contexts'; import { HistogramResult } from '../../../../common/types'; import { useUrlParams } from '../../../hooks'; +import { ChartEmptyState } from './chart_empty_state'; export interface PingHistogramComponentProps { /** @@ -49,71 +50,36 @@ export const PingHistogramComponent: React.FC = ({ const [, updateUrlParams] = useUrlParams(); - if (!data || !data.histogram) - /** - * TODO: the Fragment, EuiTitle, and EuiPanel should be extracted to a dumb component - * that we can reuse in the subsequent return statement at the bottom of this function. - */ - return ( - <> - -
- -
-
- - -
- -
- - } - body={ -

- -

- } - /> -
- + let content: JSX.Element | undefined; + if (!data?.histogram?.length) { + content = ( + ); - const { histogram } = data; - - const downSpecId = i18n.translate('xpack.uptime.snapshotHistogram.downMonitorsId', { - defaultMessage: 'Down Monitors', - }); + } else { + const { histogram } = data; - const upMonitorsId = i18n.translate('xpack.uptime.snapshotHistogram.series.upLabel', { - defaultMessage: 'Up', - }); + const downSpecId = i18n.translate('xpack.uptime.snapshotHistogram.downMonitorsId', { + defaultMessage: 'Down Monitors', + }); - const onBrushEnd = (min: number, max: number) => { - updateUrlParams({ - dateRangeStart: moment(min).toISOString(), - dateRangeEnd: moment(max).toISOString(), + const upMonitorsId = i18n.translate('xpack.uptime.snapshotHistogram.series.upLabel', { + defaultMessage: 'Up', }); - }; - return ( - <> - -

- -

-
+ + const onBrushEnd = (min: number, max: number) => { + updateUrlParams({ + dateRangeStart: moment(min).toISOString(), + dateRangeEnd: moment(max).toISOString(), + }); + }; + content = ( = ({ /> + ); + } + + return ( + <> + +

+ +

+
+ {content} ); }; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap index 6064caa868bf8..f637af397bbeb 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap @@ -51,6 +51,6 @@ exports[`MonitorPage shallow renders expected elements for valid props 1`] = ` } } > - + `; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index 2563b15eed5d5..58d98af5d6b14 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -1,836 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PageHeaderComponent mount expected page title for valid monitor route 1`] = ` - - - - -
- -
- -

- https://www.elastic.co -

-
-
-
- -
- - - -
- -
- - } - > -
- - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="QuickSelectPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - > - -
-
- - - -
-
-
-
-
-
- } - iconType={false} - isCustom={true} - startDateControl={
} - > -
- -
- - -
-
- -
- - -
- - - - - - - - - -
-
-
- - - -
- -
- - -
- - - - -`; - -exports[`PageHeaderComponent renders expected elements for valid props 1`] = ` -Array [ -
-
-

- Overview -

-
-
-
-
-
-
-
- -
-
-
-
- -
-
-
-
-
- - - -
-
-
-
, -
, -] -`; - -exports[`PageHeaderComponent renders expected title for valid monitor route 1`] = ` +exports[`PageHeader shallow renders with breadcrumbs and the date picker: page_header_with_date_picker 1`] = ` Array [
- https://www.elastic.co + TestingHeading
- Overview + TestingHeading
-
-
-
-
-
-
- -
-
-
-
- -
-
-
-
-
- - - -
-
-
,
, ] `; - -exports[`PageHeaderComponent shallow renders expected elements for valid props 1`] = ` - - - -`; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx b/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx index 38d074cdb5dba..c1149834b4f59 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx @@ -6,141 +6,74 @@ import React from 'react'; import { Route } from 'react-router-dom'; -import { PageHeaderComponent } from '../page_header'; -import { mountWithRouter, renderWithRouter, shallowWithRouter } from '../../lib'; -import { MONITOR_ROUTE, OVERVIEW_ROUTE } from '../../../common/constants'; -import { Ping } from '../../../common/graphql/types'; -import { createMemoryHistory } from 'history'; +import { PageHeader, makeBaseBreadcrumb } from '../page_header'; +import { mountWithRouter, renderWithRouter } from '../../lib'; +import { OVERVIEW_ROUTE } from '../../../common/constants'; import { ChromeBreadcrumb } from 'kibana/public'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { UptimeUrlParams, getSupportedUrlParams } from '../../lib/helper'; -describe('PageHeaderComponent', () => { - const monitorStatus: Ping = { - id: 'elastic-co', - tcp: { rtt: { connect: { us: 174982 } } }, - http: { - response: { - body: { - bytes: 2092041, - hash: '5d970606a6be810ae5d37115c4807fdd07ba4c3e407924ee5297e172d2efb3dc', - }, - status_code: 200, - }, - rtt: { - response_header: { us: 340175 }, - write_request: { us: 38 }, - validate: { us: 1797839 }, - content: { us: 1457663 }, - total: { us: 2030012 }, - }, - }, - monitor: { - ip: '2a04:4e42:3::729', - status: 'up', - duration: { us: 2030035 }, - type: 'http', - id: 'elastic-co', - name: 'elastic', - check_group: '2a017afa-4736-11ea-b3d0-acde48001122', - }, - resolve: { ip: '2a04:4e42:3::729', rtt: { us: 2102 } }, - url: { port: 443, full: 'https://www.elastic.co', scheme: 'https', domain: 'www.elastic.co' }, - ecs: { version: '1.4.0' }, - tls: { - certificate_not_valid_after: '2020-07-16T03:15:39.000Z', - rtt: { handshake: { us: 57115 } }, - certificate_not_valid_before: '2019-08-16T01:40:25.000Z', - }, - observer: { - geo: { name: 'US-West', location: '37.422994, -122.083666' }, - }, - timestamp: '2020-02-04T10:07:42.142Z', - }; - - it('shallow renders expected elements for valid props', () => { - const component = shallowWithRouter(); - expect(component).toMatchSnapshot(); - }); - - it('renders expected elements for valid props', () => { - const component = renderWithRouter(); - expect(component).toMatchSnapshot(); - }); +describe('PageHeader', () => { + const simpleBreadcrumbs: ChromeBreadcrumb[] = [ + { text: 'TestCrumb1', href: '#testHref1' }, + { text: 'TestCrumb2', href: '#testHref2' }, + ]; - it('renders expected title for valid overview route', () => { + it('shallow renders with breadcrumbs and the date picker', () => { const component = renderWithRouter( - - - + ); - expect(component).toMatchSnapshot(); - - const titleComponent = component.find('.euiTitle'); - expect(titleComponent.text()).toBe('Overview'); + expect(component).toMatchSnapshot('page_header_with_date_picker'); }); - it('renders expected title for valid monitor route', () => { - const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); - + it('shallow renders with breadcrumbs without the date picker', () => { const component = renderWithRouter( - - - , - history + ); - expect(component).toMatchSnapshot(); - - const titleComponent = component.find('.euiTitle'); - expect(titleComponent.text()).toBe('https://www.elastic.co'); + expect(component).toMatchSnapshot('page_header_no_date_picker'); }); - it('mount expected page title for valid monitor route', () => { - const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); - - const component = mountWithRouter( - - - , - history - ); - expect(component).toMatchSnapshot(); - - const titleComponent = component.find('.euiTitle'); - expect(titleComponent.text()).toBe('https://www.elastic.co'); - expect(document.title).toBe('Uptime | elastic - Kibana'); - }); - - it('mount and set expected breadcrumb for monitor route', () => { - const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); - let breadcrumbObj: ChromeBreadcrumb[] = []; - const setBreadcrumb = (breadcrumbs: ChromeBreadcrumb[]) => { - breadcrumbObj = breadcrumbs; - }; - + it('sets the given breadcrumbs', () => { + const [getBreadcrumbs, core] = mockCore(); mountWithRouter( - - - , - history + + + + + ); - expect(breadcrumbObj).toStrictEqual([ - { href: '#/?', text: 'Uptime' }, - { text: 'https://www.elastic.co' }, - ]); - }); - - it('mount and set expected breadcrumb for overview route', () => { - let breadcrumbObj: ChromeBreadcrumb[] = []; - const setBreadcrumb = (breadcrumbs: ChromeBreadcrumb[]) => { - breadcrumbObj = breadcrumbs; - }; - - mountWithRouter( - - - + const urlParams: UptimeUrlParams = getSupportedUrlParams({}); + expect(getBreadcrumbs()).toStrictEqual( + [makeBaseBreadcrumb(urlParams)].concat(simpleBreadcrumbs) ); - - expect(breadcrumbObj).toStrictEqual([{ href: '#/', text: 'Uptime' }]); }); }); + +const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { + let breadcrumbObj: ChromeBreadcrumb[] = []; + const get = () => { + return breadcrumbObj; + }; + const core = { + chrome: { + setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => { + breadcrumbObj = newBreadcrumbs; + }, + }, + }; + + return [get, core]; +}; diff --git a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx index 380cc041ae87e..8c608f57a9592 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx @@ -5,19 +5,45 @@ */ import { EuiSpacer } from '@elastic/eui'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { ChromeBreadcrumb } from 'kibana/public'; +import { connect, MapDispatchToPropsFunction, MapStateToPropsParam } from 'react-redux'; import { MonitorCharts, PingList } from '../components/functional'; import { UptimeRefreshContext, UptimeThemeContext } from '../contexts'; import { useUptimeTelemetry, useUrlParams, UptimePage } from '../hooks'; import { useTrackPageview } from '../../../../../plugins/observability/public'; import { MonitorStatusDetails } from '../components/connected'; +import { Ping } from '../../common/graphql/types'; +import { AppState } from '../state'; +import { selectSelectedMonitor } from '../state/selectors'; +import { getSelectedMonitor } from '../state/actions'; +import { PageHeader } from './page_header'; -export const MonitorPage = () => { +interface StateProps { + selectedMonitor: Ping | null; +} + +interface DispatchProps { + dispatchGetMonitorStatus: (monitorId: string) => void; +} + +type Props = StateProps & DispatchProps; + +export const MonitorPageComponent: React.FC = ({ + selectedMonitor, + dispatchGetMonitorStatus, +}: Props) => { // decode 64 base string, it was decoded to make it a valid url, since monitor id can be a url let { monitorId } = useParams(); monitorId = atob(monitorId || ''); + useEffect(() => { + if (monitorId) { + dispatchGetMonitorStatus(monitorId); + } + }, [dispatchGetMonitorStatus, monitorId]); + const [pingListPageCount, setPingListPageCount] = useState(10); const { colors } = useContext(UptimeThemeContext); const { refreshApp } = useContext(UptimeRefreshContext); @@ -39,8 +65,11 @@ export const MonitorPage = () => { useTrackPageview({ app: 'uptime', path: 'monitor' }); useTrackPageview({ app: 'uptime', path: 'monitor', delay: 15000 }); + const nameOrId = selectedMonitor?.monitor?.name || selectedMonitor?.monitor?.id || ''; + const breadcrumbs: ChromeBreadcrumb[] = [{ text: nameOrId }]; return ( <> + @@ -65,3 +94,21 @@ export const MonitorPage = () => { ); }; + +const mapStateToProps: MapStateToPropsParam = state => ({ + selectedMonitor: selectSelectedMonitor(state), +}); + +const mapDispatchToProps: MapDispatchToPropsFunction = (dispatch, own) => { + return { + dispatchGetMonitorStatus: (monitorId: string) => { + dispatch( + getSelectedMonitor({ + monitorId, + }) + ); + }, + }; +}; + +export const MonitorPage = connect(mapStateToProps, mapDispatchToProps)(MonitorPageComponent); diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index cf3631eda042a..15e31d5e44629 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -7,6 +7,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useContext, useEffect } from 'react'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; import { EmptyState, MonitorList, @@ -20,6 +21,7 @@ import { DataPublicPluginSetup, IIndexPattern } from '../../../../../../src/plug import { UptimeThemeContext } from '../contexts'; import { FilterGroup, KueryBar } from '../components/connected'; import { useUpdateKueryString } from '../hooks'; +import { PageHeader } from './page_header'; interface OverviewPageProps { autocomplete: DataPublicPluginSetup['autocomplete']; @@ -71,8 +73,14 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi const linkParameters = stringifyUrlParams(params, true); + const heading = i18n.translate('xpack.uptime.overviewPage.headerText', { + defaultMessage: 'Overview', + description: `The text that will be displayed in the app's heading when the Overview page loads.`, + }); + return ( <> + diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx index 5c051c491c6f5..b0fb2d0ed7869 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -4,75 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ +import React, { useEffect } from 'react'; +import { ChromeBreadcrumb } from 'kibana/public'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useEffect, useState } from 'react'; -import { useRouteMatch } from 'react-router-dom'; -import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { UptimeDatePicker } from '../components/functional/uptime_date_picker'; -import { getMonitorPageBreadcrumb, getOverviewPageBreadcrumbs } from '../breadcrumbs'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; -import { getTitle } from '../lib/helper/get_title'; -import { UMUpdateBreadcrumbs } from '../lib/lib'; import { useUrlParams } from '../hooks'; -import { MONITOR_ROUTE } from '../../common/constants'; -import { Ping } from '../../common/graphql/types'; +import { UptimeUrlParams } from '../lib/helper'; interface PageHeaderProps { - monitorStatus?: Ping; - setBreadcrumbs: UMUpdateBreadcrumbs; + headingText: string; + breadcrumbs: ChromeBreadcrumb[]; + datePicker: boolean; } -export const PageHeaderComponent = ({ monitorStatus, setBreadcrumbs }: PageHeaderProps) => { - const monitorPage = useRouteMatch({ - path: MONITOR_ROUTE, - }); - - const [getUrlParams] = useUrlParams(); - const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); - - const headingText = !monitorPage - ? i18n.translate('xpack.uptime.overviewPage.headerText', { - defaultMessage: 'Overview', - description: `The text that will be displayed in the app's heading when the Overview page loads.`, - }) - : monitorStatus?.url?.full; +export const makeBaseBreadcrumb = (params?: UptimeUrlParams): ChromeBreadcrumb => { + let href = '#/'; + if (params) { + const crumbParams: Partial = { ...params }; + // We don't want to encode this values because they are often set to Date.now(), the relative + // values in dateRangeStart are better for a URL. + delete crumbParams.absoluteDateRangeStart; + delete crumbParams.absoluteDateRangeEnd; + href += stringifyUrlParams(crumbParams, true); + } + return { + text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { + defaultMessage: 'Uptime', + }), + href, + }; +}; - const [headerText, setHeaderText] = useState(headingText); +export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: PageHeaderProps) => { + const setBreadcrumbs = useKibana().services.chrome?.setBreadcrumbs!; + const params = useUrlParams()[0](); useEffect(() => { - if (monitorPage) { - setHeaderText(monitorStatus?.url?.full ?? ''); - if (monitorStatus?.monitor) { - const { name, id } = monitorStatus.monitor; - document.title = getTitle((name || id) ?? ''); - } - } else { - setHeaderText(headingText); - document.title = getTitle(); - } - }, [monitorStatus, monitorPage, setHeaderText, headingText]); + setBreadcrumbs([makeBaseBreadcrumb(params)].concat(breadcrumbs)); + }, [breadcrumbs, params, setBreadcrumbs]); - useEffect(() => { - if (monitorPage) { - if (headerText) { - setBreadcrumbs(getMonitorPageBreadcrumb(headerText, stringifyUrlParams(params, true))); - } - } else { - setBreadcrumbs(getOverviewPageBreadcrumbs()); - } - }, [headerText, setBreadcrumbs, params, monitorPage]); + const datePickerComponent = datePicker ? ( + + + + ) : null; return ( <> -

{headerText}

+

{headingText}

- - - + {datePickerComponent}
diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index 66ff5ba7a58ee..427870797a206 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -13,7 +13,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import { I18nStart, ChromeBreadcrumb, CoreStart } from 'src/core/public'; import { PluginsSetup } from 'ui/new_platform/new_platform'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { UMGraphQLClient, UMUpdateBreadcrumbs, UMUpdateBadge } from './lib/lib'; +import { UMGraphQLClient, UMUpdateBadge } from './lib/lib'; import { UptimeRefreshContextProvider, UptimeSettingsContextProvider, @@ -23,7 +23,6 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { store } from './state'; import { setBasePath } from './state/actions'; import { PageRouter } from './routes'; -import { PageHeader } from './components/connected/pages/page_header_container'; export interface UptimeAppColors { danger: string; @@ -47,10 +46,10 @@ export interface UptimeAppProps { kibanaBreadcrumbs: ChromeBreadcrumb[]; plugins: PluginsSetup; routerBasename: string; - setBreadcrumbs: UMUpdateBreadcrumbs; setBadge: UMUpdateBadge; renderGlobalHelpControls(): void; commonlyUsedRanges: CommonlyUsedRange[]; + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; } const Application = (props: UptimeAppProps) => { @@ -64,7 +63,6 @@ const Application = (props: UptimeAppProps) => { plugins, renderGlobalHelpControls, routerBasename, - setBreadcrumbs, setBadge, } = props; @@ -100,7 +98,6 @@ const Application = (props: UptimeAppProps) => {
-
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md index b1a9e6daaaee3..582c9df731a15 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md @@ -58,43 +58,47 @@ Finally, create the alert: ``` kbn-alert create .index-threshold 'es-hb-sim threshold' 1s \ '{ - index: es-hb-sim - timeField: @timestamp - aggType: average - aggField: summary.up - groupField: monitor.name.keyword - window: 5s - comparator: lessThan - threshold: [ 0.6 ] + index: es-hb-sim + timeField: @timestamp + aggType: avg + aggField: summary.up + groupBy: top + termSize: 100 + termField: monitor.name.keyword + timeWindowSize: 5 + timeWindowUnit: s + thresholdComparator: < + threshold: [ 0.6 ] }' \ "[ { - group: threshold met - id: '$ACTION_ID' + group: threshold met + id: '$ACTION_ID' params: { - level: warn - message: '{{context.message}}' + level: warn + message: '{{{context.message}}}' } } ]" ``` This alert will run a query over the `es-hb-sim` index, using the `@timestamp` -field as the date field, using an `average` aggregation over the `summary.up` -field. The results are then aggregated by `monitor.name.keyword`. If we ran +field as the date field, aggregating over groups of the field value +`monitor.name.keyword` (the top 100 groups), then aggregating those values +using an `average` aggregation over the `summary.up` field. If we ran another instance of `es-hb-sim`, using `host-B` instead of `host-A`, then the alert will end up potentially scheduling actions for both, independently. Within the alerting plugin, this grouping is also referred to as "instanceIds" (`host-A` and `host-B` being distinct instanceIds, which can have actions scheduled against them independently). -The `window` is set to `5s` which is 5 seconds. That means, every time the +The time window is set to 5 seconds. That means, every time the alert runs it's queries (every second, in the example above), it will run it's ES query over the last 5 seconds. Thus, the queries, over time, will overlap. Sometimes that's what you want. Other times, maybe you just want to do sampling, running an alert every hour, with a 5 minute window. Up to the you! -Using the `comparator` `lessThan` and `threshold` `[0.6]`, the alert will +Using the `thresholdComparator` `<` and `threshold` `[0.6]`, the alert will calculate the average of all the `summary.up` fields for each unique `monitor.name.keyword`, and then if the value is less than 0.6, it will schedule the specified action (server log) to run. The `message` param @@ -110,11 +114,10 @@ working: ``` server log [17:32:10.060] [warning][actions][actions][plugins] \ - Server log: alert es-hb-sim threshold instance host-A value 0 \ - exceeded threshold average(summary.up) lessThan 0.6 over 5s \ + Server log: alert es-hb-sim threshold group host-A value 0 \ + exceeded threshold avg(summary.up) < 0.6 over 5s \ on 2020-02-20T22:32:07.000Z ``` - [kbn-action]: https://github.com/pmuellr/kbn-action [es-hb-sim]: https://github.com/pmuellr/es-hb-sim [now-iso]: https://github.com/pmuellr/now-iso @@ -144,15 +147,18 @@ This example uses [now-iso][] to generate iso date strings. ```console curl -k "https://elastic:changeme@localhost:5601/api/alerting_builtins/index_threshold/_time_series_query" \ -H "kbn-xsrf: foo" -H "content-type: application/json" -d "{ - \"index\": \"es-hb-sim\", - \"timeField\": \"@timestamp\", - \"aggType\": \"average\", - \"aggField\": \"summary.up\", - \"groupField\": \"monitor.name.keyword\", - \"interval\": \"1s\", - \"dateStart\": \"`now-iso -10s`\", - \"dateEnd\": \"`now-iso`\", - \"window\": \"5s\" + \"index\": \"es-hb-sim\", + \"timeField\": \"@timestamp\", + \"aggType\": \"avg\", + \"aggField\": \"summary.up\", + \"groupBy\": \"top\", + \"termSize\": 100, + \"termField\": \"monitor.name.keyword\", + \"interval\": \"1s\", + \"dateStart\": \"`now-iso -10s`\", + \"dateEnd\": \"`now-iso`\", + \"timeWindowSize\": 5, + \"timeWindowUnit\": \"s\" }" ``` @@ -184,13 +190,16 @@ To get the current value of the calculated metric, you can leave off the date: ``` curl -k "https://elastic:changeme@localhost:5601/api/alerting_builtins/index_threshold/_time_series_query" \ -H "kbn-xsrf: foo" -H "content-type: application/json" -d '{ - "index": "es-hb-sim", - "timeField": "@timestamp", - "aggType": "average", - "aggField": "summary.up", - "groupField": "monitor.name.keyword", - "interval": "1s", - "window": "5s" + "index": "es-hb-sim", + "timeField": "@timestamp", + "aggType": "avg", + "aggField": "summary.up", + "groupBy": "top", + "termField": "monitor.name.keyword", + "termSize": 100, + "interval": "1s", + "timeWindowSize": 5, + "timeWindowUnit": "s" }' ``` @@ -254,7 +263,7 @@ be ~24 time series points in the output. For preview purposes: -- The `groupLimit` parameter should be used to help cut +- The `termSize` parameter should be used to help cut down on the amount of work ES does, and keep the generated graphs a little simpler. Probably something like `10`. @@ -263,9 +272,9 @@ simpler. Probably something like `10`. could result in a lot of time-series points being generated, which is both costly in ES, and may result in noisy graphs. -- The `window` parameter should be the same as what the alert is using, +- The `timeWindow*` parameters should be the same as what the alert is using, especially for the `count` and `sum` aggregation types. Those aggregations don't scale the same way the others do, when the window changes. Even for the other aggregations, changing the window could result in dramatically -different values being generated - `averages` will be more "average-y", `min` +different values being generated - `avg` will be more "average-y", `min` and `max` will be a little stickier. \ No newline at end of file diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts index fbadf14f1d560..e4cba7855e5f6 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts @@ -21,8 +21,12 @@ describe('ActionContext', () => { index: '[index]', timeField: '[timeField]', aggType: 'count', - window: '5m', - comparator: 'greaterThan', + groupBy: 'top', + termField: 'x', + termSize: 100, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', threshold: [4], }); const context = addMessages(base, params); @@ -30,7 +34,7 @@ describe('ActionContext', () => { `"alert [name] group [group] exceeded threshold"` ); expect(context.message).toMatchInlineSnapshot( - `"alert [name] group [group] value 42 exceeded threshold count greaterThan 4 over 5m on 2020-01-01T00:00:00.000Z"` + `"alert [name] group [group] value 42 exceeded threshold count > 4 over 5m on 2020-01-01T00:00:00.000Z"` ); }); @@ -46,10 +50,14 @@ describe('ActionContext', () => { const params = ParamsSchema.validate({ index: '[index]', timeField: '[timeField]', - aggType: 'average', + aggType: 'avg', + groupBy: 'top', + termField: 'x', + termSize: 100, aggField: '[aggField]', - window: '5m', - comparator: 'greaterThan', + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', threshold: [4.2], }); const context = addMessages(base, params); @@ -57,7 +65,7 @@ describe('ActionContext', () => { `"alert [name] group [group] exceeded threshold"` ); expect(context.message).toMatchInlineSnapshot( - `"alert [name] group [group] value 42 exceeded threshold average([aggField]) greaterThan 4.2 over 5m on 2020-01-01T00:00:00.000Z"` + `"alert [name] group [group] value 42 exceeded threshold avg([aggField]) > 4.2 over 5m on 2020-01-01T00:00:00.000Z"` ); }); @@ -74,8 +82,12 @@ describe('ActionContext', () => { index: '[index]', timeField: '[timeField]', aggType: 'count', - window: '5m', - comparator: 'between', + groupBy: 'top', + termField: 'x', + termSize: 100, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: 'between', threshold: [4, 5], }); const context = addMessages(base, params); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts index 98a8e5ae14b7f..72e42c7c0c2fa 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts @@ -47,8 +47,9 @@ export function addMessages(c: BaseActionContext, p: Params): ActionContext { ); const agg = p.aggField ? `${p.aggType}(${p.aggField})` : `${p.aggType}`; - const humanFn = `${agg} ${p.comparator} ${p.threshold.join(',')}`; + const humanFn = `${agg} ${p.thresholdComparator} ${p.threshold.join(',')}`; + const window = `${p.timeWindowSize}${p.timeWindowUnit}`; const message = i18n.translate( 'xpack.alertingBuiltins.indexThreshold.alertTypeContextMessageDescription', { @@ -59,7 +60,7 @@ export function addMessages(c: BaseActionContext, p: Params): ActionContext { group: c.group, value: c.value, function: humanFn, - window: p.window, + window, date: c.date, }, } diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts index f6e26cdaa283a..5034b1ee0cd01 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts @@ -6,6 +6,7 @@ import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; import { getAlertType } from './alert_type'; +import { Params } from './alert_type_params'; describe('alertType', () => { const service = { @@ -24,12 +25,14 @@ describe('alertType', () => { }); it('validator succeeds with valid params', async () => { - const params = { + const params: Partial> = { index: 'index-name', timeField: 'time-field', aggType: 'count', - window: '5m', - comparator: 'greaterThan', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '<', threshold: [0], }; @@ -40,12 +43,14 @@ describe('alertType', () => { const paramsSchema = alertType.validate?.params; if (!paramsSchema) throw new Error('params validator not set'); - const params = { + const params: Partial> = { index: 'index-name', timeField: 'time-field', aggType: 'foo', - window: '5m', - comparator: 'greaterThan', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', threshold: [0], }; diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts index 2b0c07ed4355a..4610e0fbaf0da 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { AlertType, AlertExecutorOptions } from '../../types'; import { Params, ParamsSchema } from './alert_type_params'; import { BaseActionContext, addMessages } from './action_context'; +import { TimeSeriesQuery } from './lib/time_series_query'; export const ID = '.index-threshold'; @@ -46,24 +47,26 @@ export function getAlertType(service: Service): AlertType { const { alertId, name, services } = options; const params: Params = options.params as Params; - const compareFn = ComparatorFns.get(params.comparator); + const compareFn = ComparatorFns.get(params.thresholdComparator); if (compareFn == null) { - throw new Error(getInvalidComparatorMessage(params.comparator)); + throw new Error(getInvalidComparatorMessage(params.thresholdComparator)); } const callCluster = services.callCluster; const date = new Date().toISOString(); // the undefined values below are for config-schema optional types - const queryParams = { + const queryParams: TimeSeriesQuery = { index: params.index, timeField: params.timeField, aggType: params.aggType, aggField: params.aggField, - groupField: params.groupField, - groupLimit: params.groupLimit, + groupBy: params.groupBy, + termField: params.termField, + termSize: params.termSize, dateStart: date, dateEnd: date, - window: params.window, + timeWindowSize: params.timeWindowSize, + timeWindowUnit: params.timeWindowUnit, interval: undefined, }; const result = await service.indexThreshold.timeSeriesQuery({ @@ -100,7 +103,7 @@ export function getAlertType(service: Service): AlertType { export function getInvalidComparatorMessage(comparator: string) { return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidComparatorErrorMessage', { - defaultMessage: 'invalid comparator specified: {comparator}', + defaultMessage: 'invalid thresholdComparator specified: {comparator}', values: { comparator, }, @@ -111,10 +114,10 @@ type ComparatorFn = (value: number, threshold: number[]) => boolean; function getComparatorFns(): Map { const fns: Record = { - lessThan: (value: number, threshold: number[]) => value < threshold[0], - lessThanOrEqual: (value: number, threshold: number[]) => value <= threshold[0], - greaterThanOrEqual: (value: number, threshold: number[]) => value >= threshold[0], - greaterThan: (value: number, threshold: number[]) => value > threshold[0], + '<': (value: number, threshold: number[]) => value < threshold[0], + '<=': (value: number, threshold: number[]) => value <= threshold[0], + '>=': (value: number, threshold: number[]) => value >= threshold[0], + '>': (value: number, threshold: number[]) => value > threshold[0], between: (value: number, threshold: number[]) => value >= threshold[0] && value <= threshold[1], notBetween: (value: number, threshold: number[]) => value < threshold[0] || value > threshold[1], diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts index b9f66cfa7a253..33d1e1897e943 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ParamsSchema } from './alert_type_params'; +import { ParamsSchema, Params } from './alert_type_params'; import { runTests } from './lib/core_query_types.test'; -const DefaultParams = { +const DefaultParams: Writable> = { index: 'index-name', timeField: 'time-field', aggType: 'count', - window: '5m', - comparator: 'greaterThan', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', threshold: [0], }; @@ -29,28 +31,29 @@ describe('alertType Params validate()', () => { }); it('passes for maximal valid input', async () => { - params.aggType = 'average'; + params.aggType = 'avg'; params.aggField = 'agg-field'; - params.groupField = 'group-field'; - params.groupLimit = 100; + params.groupBy = 'top'; + params.termField = 'group-field'; + params.termSize = 100; expect(validate()).toBeTruthy(); }); it('fails for invalid comparator', async () => { - params.comparator = '[invalid-comparator]'; + params.thresholdComparator = '[invalid-comparator]'; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[comparator]: invalid comparator specified: [invalid-comparator]"` + `"[thresholdComparator]: invalid thresholdComparator specified: [invalid-comparator]"` ); }); it('fails for invalid threshold length', async () => { - params.comparator = 'lessThan'; - params.threshold = [0, 1]; + params.thresholdComparator = '<'; + params.threshold = [0, 1, 2]; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[threshold]: must have one element for the \\"lessThan\\" comparator"` + `"[threshold]: array size is [3], but cannot be greater than [2]"` ); - params.comparator = 'between'; + params.thresholdComparator = 'between'; params.threshold = [0]; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( `"[threshold]: must have two elements for the \\"between\\" comparator"` diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts index d5b83f9f6ad5a..f83d7fa07cd2a 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts @@ -17,7 +17,7 @@ export const ParamsSchema = schema.object( { ...CoreQueryParamsSchemaProperties, // the comparison function to use to determine if the threshold as been met - comparator: schema.string({ validate: validateComparator }), + thresholdComparator: schema.string({ validate: validateComparator }), // the values to use as the threshold; `between` and `notBetween` require // two values, the others require one. threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), @@ -35,26 +35,16 @@ function validateParams(anyParams: any): string | undefined { const coreQueryValidated = validateCoreQueryBody(anyParams); if (coreQueryValidated) return coreQueryValidated; - const { comparator, threshold }: Params = anyParams; + const { thresholdComparator, threshold }: Params = anyParams; - if (betweenComparators.has(comparator)) { - if (threshold.length === 1) { - return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold2ErrorMessage', { - defaultMessage: '[threshold]: must have two elements for the "{comparator}" comparator', - values: { - comparator, - }, - }); - } - } else { - if (threshold.length === 2) { - return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold1ErrorMessage', { - defaultMessage: '[threshold]: must have one element for the "{comparator}" comparator', - values: { - comparator, - }, - }); - } + if (betweenComparators.has(thresholdComparator) && threshold.length === 1) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold2ErrorMessage', { + defaultMessage: + '[threshold]: must have two elements for the "{thresholdComparator}" comparator', + values: { + thresholdComparator, + }, + }); } } diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts index b4f061adb8f54..d67d29cacde42 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts @@ -7,14 +7,16 @@ // tests of common properties on time_series_query and alert_type_params import { ObjectType } from '@kbn/config-schema'; - +import { CoreQueryParams } from './core_query_types'; import { MAX_GROUPS } from '../index'; -const DefaultParams: Record = { +const DefaultParams: Writable> = { index: 'index-name', timeField: 'time-field', aggType: 'count', - window: '5m', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', }; export function runTests(schema: ObjectType, defaultTypeParams: Record): void { @@ -30,28 +32,48 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record { - params.aggType = 'average'; + params.aggType = 'avg'; + params.aggField = 'agg-field'; + params.groupBy = 'top'; + params.termField = 'group-field'; + params.termSize = 200; + expect(validate()).toBeTruthy(); + + params.index = ['index-name-1', 'index-name-2']; + params.aggType = 'avg'; params.aggField = 'agg-field'; - params.groupField = 'group-field'; - params.groupLimit = 200; + params.groupBy = 'top'; + params.termField = 'group-field'; + params.termSize = 200; expect(validate()).toBeTruthy(); }); it('fails for invalid index', async () => { delete params.index; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[index]: expected value of type [string] but got [undefined]"` + `"[index]: expected at least one defined value but got [undefined]"` ); params.index = 42; - expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[index]: expected value of type [string] but got [number]"` - ); + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(` +"[index]: types that failed validation: +- [index.0]: expected value of type [string] but got [number] +- [index.1]: expected value of type [array] but got [number]" +`); params.index = ''; - expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[index]: value is [] but it must have a minimum length of [1]."` - ); + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(` +"[index]: types that failed validation: +- [index.0]: value is [] but it must have a minimum length of [1]. +- [index.1]: could not parse array value from []" +`); + + params.index = ['', 'a']; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(` +"[index]: types that failed validation: +- [index.0]: expected value of type [string] but got [Array] +- [index.1.0]: value is [] but it must have a minimum length of [1]." +`); }); it('fails for invalid timeField', async () => { @@ -95,58 +117,67 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record { - params.groupField = 42; + it('fails for invalid termField', async () => { + params.groupBy = 'top'; + params.termField = 42; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupField]: expected value of type [string] but got [number]"` + `"[termField]: expected value of type [string] but got [number]"` ); - params.groupField = ''; + params.termField = ''; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupField]: value is [] but it must have a minimum length of [1]."` + `"[termField]: value is [] but it must have a minimum length of [1]."` ); }); - it('fails for invalid groupLimit', async () => { - params.groupLimit = 'foo'; + it('fails for invalid termSize', async () => { + params.groupBy = 'top'; + params.termField = 'fee'; + params.termSize = 'foo'; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupLimit]: expected value of type [number] but got [string]"` + `"[termSize]: expected value of type [number] but got [string]"` ); - params.groupLimit = 0; + params.termSize = MAX_GROUPS + 1; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupLimit]: must be greater than 0"` + `"[termSize]: must be less than or equal to 1000"` ); - params.groupLimit = MAX_GROUPS + 1; + params.termSize = 0; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupLimit]: must be less than or equal to 1000"` + `"[termSize]: Value is [0] but it must be equal to or greater than [1]."` ); }); - it('fails for invalid window', async () => { - params.window = 42; + it('fails for invalid timeWindowSize', async () => { + params.timeWindowSize = 'foo'; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[window]: expected value of type [string] but got [number]"` + `"[timeWindowSize]: expected value of type [number] but got [string]"` ); - params.window = 'x'; + params.timeWindowSize = 0; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[window]: invalid duration: \\"x\\""` + `"[timeWindowSize]: Value is [0] but it must be equal to or greater than [1]."` ); }); - it('fails for invalid aggType/aggField', async () => { - params.aggType = 'count'; - params.aggField = 'agg-field-1'; + it('fails for invalid timeWindowUnit', async () => { + params.timeWindowUnit = 42; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[aggField]: must not have a value when [aggType] is \\"count\\""` + `"[timeWindowUnit]: expected value of type [string] but got [number]"` ); - params.aggType = 'average'; + params.timeWindowUnit = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowUnit]: invalid timeWindowUnit: \\"x\\""` + ); + }); + + it('fails for invalid aggType/aggField', async () => { + params.aggType = 'avg'; delete params.aggField; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[aggField]: must have a value when [aggType] is \\"average\\""` + `"[aggField]: must have a value when [aggType] is \\"avg\\""` ); }); }); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts index 265a70eba4d6b..6e9c0072bf7b6 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts @@ -10,23 +10,29 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { MAX_GROUPS } from '../index'; -import { parseDuration } from '../../../../../alerting/server'; export const CoreQueryParamsSchemaProperties = { - // name of the index to search - index: schema.string({ minLength: 1 }), + // name of the indices to search + index: schema.oneOf([ + schema.string({ minLength: 1 }), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + ]), // field in index used for date/time timeField: schema.string({ minLength: 1 }), // aggregation type aggType: schema.string({ validate: validateAggType }), // aggregation field aggField: schema.maybe(schema.string({ minLength: 1 })), - // group field - groupField: schema.maybe(schema.string({ minLength: 1 })), + // how to group + groupBy: schema.string({ validate: validateGroupBy }), + // field to group on (for groupBy: top) + termField: schema.maybe(schema.string({ minLength: 1 })), // limit on number of groups returned - groupLimit: schema.maybe(schema.number()), + termSize: schema.maybe(schema.number({ min: 1 })), // size of time window for date range aggregations - window: schema.string({ validate: validateDuration }), + timeWindowSize: schema.number({ min: 1 }), + // units of time window for date range aggregations + timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }), }; const CoreQueryParamsSchema = schema.object(CoreQueryParamsSchemaProperties); @@ -37,17 +43,7 @@ export type CoreQueryParams = TypeOf; // above. // Using direct type not allowed, circular reference, so body is typed to any. export function validateCoreQueryBody(anyParams: any): string | undefined { - const { aggType, aggField, groupLimit }: CoreQueryParams = anyParams; - - if (aggType === 'count' && aggField) { - return i18n.translate('xpack.alertingBuiltins.indexThreshold.aggTypeNotEmptyErrorMessage', { - defaultMessage: '[aggField]: must not have a value when [aggType] is "{aggType}"', - values: { - aggType, - }, - }); - } - + const { aggType, aggField, groupBy, termField, termSize }: CoreQueryParams = anyParams; if (aggType !== 'count' && !aggField) { return i18n.translate('xpack.alertingBuiltins.indexThreshold.aggTypeRequiredErrorMessage', { defaultMessage: '[aggField]: must have a value when [aggType] is "{aggType}"', @@ -57,21 +53,23 @@ export function validateCoreQueryBody(anyParams: any): string | undefined { }); } - // schema.number doesn't seem to check the max value ... - if (groupLimit != null) { - if (groupLimit <= 0) { - return i18n.translate( - 'xpack.alertingBuiltins.indexThreshold.invalidGroupMinimumErrorMessage', - { - defaultMessage: '[groupLimit]: must be greater than 0', - } - ); + // check grouping + if (groupBy === 'top') { + if (termField == null) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.termFieldRequiredErrorMessage', { + defaultMessage: '[termField]: termField required when [groupBy] is top', + }); + } + if (termSize == null) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.termSizeRequiredErrorMessage', { + defaultMessage: '[termSize]: termSize required when [groupBy] is top', + }); } - if (groupLimit > MAX_GROUPS) { + if (termSize > MAX_GROUPS) { return i18n.translate( - 'xpack.alertingBuiltins.indexThreshold.invalidGroupMaximumErrorMessage', + 'xpack.alertingBuiltins.indexThreshold.invalidTermSizeMaximumErrorMessage', { - defaultMessage: '[groupLimit]: must be less than or equal to {maxGroups}', + defaultMessage: '[termSize]: must be less than or equal to {maxGroups}', values: { maxGroups: MAX_GROUPS, }, @@ -81,10 +79,12 @@ export function validateCoreQueryBody(anyParams: any): string | undefined { } } -const AggTypes = new Set(['count', 'average', 'min', 'max', 'sum']); +const AggTypes = new Set(['count', 'avg', 'min', 'max', 'sum']); function validateAggType(aggType: string): string | undefined { - if (AggTypes.has(aggType)) return; + if (AggTypes.has(aggType)) { + return; + } return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidAggTypeErrorMessage', { defaultMessage: 'invalid aggType: "{aggType}"', @@ -94,15 +94,33 @@ function validateAggType(aggType: string): string | undefined { }); } -export function validateDuration(duration: string): string | undefined { - try { - parseDuration(duration); - } catch (err) { - return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidDurationErrorMessage', { - defaultMessage: 'invalid duration: "{duration}"', +export function validateGroupBy(groupBy: string): string | undefined { + if (groupBy === 'all' || groupBy === 'top') { + return; + } + + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidGroupByErrorMessage', { + defaultMessage: 'invalid groupBy: "{groupBy}"', + values: { + groupBy, + }, + }); +} + +const TimeWindowUnits = new Set(['s', 'm', 'h', 'd']); + +export function validateTimeWindowUnits(timeWindowUnit: string): string | undefined { + if (TimeWindowUnits.has(timeWindowUnit)) { + return; + } + + return i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.invalidTimeWindowUnitsErrorMessage', + { + defaultMessage: 'invalid timeWindowUnit: "{timeWindowUnit}"', values: { - duration, + timeWindowUnit, }, - }); - } + } + ); } diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts index 1955cdfa4cea6..d40df4c91998f 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts @@ -9,28 +9,30 @@ import { loggingServiceMock } from '../../../../../../../src/core/server/mocks'; import { coreMock } from '../../../../../../../src/core/server/mocks'; import { AlertingBuiltinsPlugin } from '../../../plugin'; -import { TimeSeriesQueryParameters, TimeSeriesResult } from './time_series_query'; +import { TimeSeriesQueryParameters, TimeSeriesResult, TimeSeriesQuery } from './time_series_query'; -type TimeSeriesQuery = (params: TimeSeriesQueryParameters) => Promise; +type TimeSeriesQueryFn = (query: TimeSeriesQueryParameters) => Promise; -const DefaultQueryParams = { +const DefaultQueryParams: TimeSeriesQuery = { index: 'index-name', timeField: 'time-field', aggType: 'count', aggField: undefined, - window: '5m', + timeWindowSize: 5, + timeWindowUnit: 'm', dateStart: undefined, dateEnd: undefined, interval: undefined, - groupField: undefined, - groupLimit: undefined, + groupBy: 'all', + termField: undefined, + termSize: undefined, }; describe('timeSeriesQuery', () => { let params: TimeSeriesQueryParameters; const mockCallCluster = jest.fn(); - let timeSeriesQuery: TimeSeriesQuery; + let timeSeriesQueryFn: TimeSeriesQueryFn; beforeEach(async () => { // rather than use the function from an import, retrieve it from the plugin @@ -38,26 +40,26 @@ describe('timeSeriesQuery', () => { const plugin = new AlertingBuiltinsPlugin(context); const coreStart = coreMock.createStart(); const service = await plugin.start(coreStart); - timeSeriesQuery = service.indexThreshold.timeSeriesQuery; + timeSeriesQueryFn = service.indexThreshold.timeSeriesQuery; mockCallCluster.mockReset(); params = { logger: loggingServiceMock.create().get(), callCluster: mockCallCluster, - query: { ...DefaultQueryParams }, + query: DefaultQueryParams, }; }); it('fails as expected when the callCluster call fails', async () => { mockCallCluster.mockRejectedValue(new Error('woopsie')); - expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot( + expect(timeSeriesQueryFn(params)).rejects.toThrowErrorMatchingInlineSnapshot( `"error running search"` ); }); it('fails as expected when the query params are invalid', async () => { params.query = { ...params.query, dateStart: 'x' }; - expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot( + expect(timeSeriesQueryFn(params)).rejects.toThrowErrorMatchingInlineSnapshot( `"invalid date format for dateStart: \\"x\\""` ); }); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts index 8ea2a7dd1dcc5..a4f64c0f37f41 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts @@ -21,8 +21,17 @@ export async function timeSeriesQuery( params: TimeSeriesQueryParameters ): Promise { const { logger, callCluster, query: queryParams } = params; - const { index, window, interval, timeField, dateStart, dateEnd } = queryParams; - + const { + index, + timeWindowSize, + timeWindowUnit, + interval, + timeField, + dateStart, + dateEnd, + } = queryParams; + + const window = `${timeWindowSize}${timeWindowUnit}`; const dateRangeInfo = getDateRangeInfo({ dateStart, dateEnd, window, interval }); // core query @@ -51,10 +60,10 @@ export async function timeSeriesQuery( }; // add the aggregations - const { aggType, aggField, groupField, groupLimit } = queryParams; + const { aggType, aggField, termField, termSize } = queryParams; const isCountAgg = aggType === 'count'; - const isGroupAgg = !!groupField; + const isGroupAgg = !!termField; let aggParent = esQuery.body; @@ -63,8 +72,8 @@ export async function timeSeriesQuery( aggParent.aggs = { groupAgg: { terms: { - field: groupField, - size: groupLimit || DEFAULT_GROUPS, + field: termField, + size: termSize || DEFAULT_GROUPS, }, }, }; @@ -83,11 +92,10 @@ export async function timeSeriesQuery( aggParent = aggParent.aggs.dateAgg; // finally, the metric aggregation, if requested - const actualAggType = aggType === 'average' ? 'avg' : aggType; if (!isCountAgg) { aggParent.aggs = { metricAgg: { - [actualAggType]: { + [aggType]: { field: aggField, }, }, diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts index d69d48efcdf6b..fcbd49b26ffd0 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimeSeriesQuerySchema } from './time_series_types'; +import { TimeSeriesQuerySchema, TimeSeriesQuery } from './time_series_types'; import { runTests } from './core_query_types.test'; -const DefaultParams = { +const DefaultParams: Writable> = { index: 'index-name', timeField: 'time-field', aggType: 'count', - window: '5m', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', }; describe('TimeSeriesParams validate()', () => { @@ -27,10 +29,11 @@ describe('TimeSeriesParams validate()', () => { }); it('passes for maximal valid input', async () => { - params.aggType = 'average'; + params.aggType = 'avg'; params.aggField = 'agg-field'; - params.groupField = 'group-field'; - params.groupLimit = 100; + params.groupBy = 'top'; + params.termField = 'group-field'; + params.termSize = 100; params.dateStart = new Date().toISOString(); params.dateEnd = new Date().toISOString(); params.interval = '1s'; diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts index a727e67c621d4..6cb21a1581113 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts @@ -12,11 +12,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { parseDuration } from '../../../../../alerting/server'; import { MAX_INTERVALS } from '../index'; -import { - CoreQueryParamsSchemaProperties, - validateCoreQueryBody, - validateDuration, -} from './core_query_types'; +import { CoreQueryParamsSchemaProperties, validateCoreQueryBody } from './core_query_types'; import { getTooManyIntervalsErrorMessage, getDateStartAfterDateEndErrorMessage, @@ -104,3 +100,16 @@ function validateDate(dateString: string): string | undefined { }); } } + +export function validateDuration(duration: string): string | undefined { + try { + parseDuration(duration); + } catch (err) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidDurationErrorMessage', { + defaultMessage: 'invalid duration: "{duration}"', + values: { + duration, + }, + }); + } +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7fdffbec78311..0504343e4dcc3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12386,17 +12386,8 @@ "xpack.transform.progress": "進捗", "xpack.transform.sourceIndex": "ソースインデックス", "xpack.transform.sourceIndexPreview.copyClipboardTooltip": "ソースインデックスプレビューの開発コンソールステートメントをクリップボードにコピーします。", - "xpack.transform.sourceIndexPreview.fieldSelection": "{docFieldsCount, number} 件中 {selectedFieldsLength, number} 件の {docFieldsCount, plural, one {フィールド} other {フィールド}}を選択済み", - "xpack.transform.sourceIndexPreview.rowCollapse": "縮小", - "xpack.transform.sourceIndexPreview.rowExpand": "拡張", - "xpack.transform.sourceIndexPreview.selectColumnsAriaLabel": "列を選択", - "xpack.transform.sourceIndexPreview.selectFieldsPopoverTitle": "フィールドを選択", - "xpack.transform.sourceIndexPreview.SourceIndexArrayBadgeContent": "配列", - "xpack.transform.sourceIndexPreview.SourceIndexArrayToolTipContent": "この配列に基づく列の完全な内容は、展開された行に表示されます。", "xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutBody": "ソースインデックスのクエリが結果を返しませんでした。インデックスにドキュメントが含まれていて、クエリ要件が妥当であることを確認してください。", "xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutTitle": "ソースインデックスクエリの結果がありません", - "xpack.transform.sourceIndexPreview.SourceIndexObjectBadgeContent": "オブジェクト", - "xpack.transform.sourceIndexPreview.SourceIndexObjectToolTipContent": "このオブジェクトベースの列の完全な内容は、展開された行に表示されます。", "xpack.transform.sourceIndexPreview.sourceIndexPatternError": "ソースインデックスデータの読み込み中にエラーが発生しました。", "xpack.transform.sourceIndexPreview.sourceIndexPatternTitle": "ソースインデックス {indexPatternTitle}", "xpack.transform.statsBar.batchTransformsLabel": "一斉", @@ -12689,11 +12680,6 @@ "xpack.uptime.kueryBar.indexPatternMissingWarningMessage": "インデックスパターンの取得中にエラーが発生しました。", "xpack.uptime.kueryBar.searchPlaceholder": "モニター ID、名前、プロトコルタイプなどを検索…", "xpack.uptime.locationName.helpLinkAnnotation": "場所を追加", - "xpack.uptime.monitorChart.checksChart.bottomAxis.title": "タイムスタンプ", - "xpack.uptime.monitorChart.checksChart.leftAxis.title": "チェックの数", - "xpack.uptime.monitorCharts.checkStatus.series.downCountLabel": "ダウンカウント", - "xpack.uptime.monitorCharts.checkStatus.series.upCountLabel": "アップカウント", - "xpack.uptime.monitorCharts.checkStatus.title": "ステータスを確認", "xpack.uptime.monitorCharts.durationChart.bottomAxis.title": "タイムスタンプ", "xpack.uptime.monitorCharts.durationChart.leftAxis.title": "期間ms", "xpack.uptime.monitorCharts.loadingMessage": "読み込み中…", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 438a8f9197508..156b1d3d24153 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12386,17 +12386,8 @@ "xpack.transform.progress": "进度", "xpack.transform.sourceIndex": "源索引", "xpack.transform.sourceIndexPreview.copyClipboardTooltip": "将源索引预览的开发控制台语句复制到剪贴板。", - "xpack.transform.sourceIndexPreview.fieldSelection": "已选择 {selectedFieldsLength, number} 个{docFieldsCount, plural, one {字段} other {字段}},共 {docFieldsCount, number} 个", - "xpack.transform.sourceIndexPreview.rowCollapse": "折叠", - "xpack.transform.sourceIndexPreview.rowExpand": "展开", - "xpack.transform.sourceIndexPreview.selectColumnsAriaLabel": "选择列", - "xpack.transform.sourceIndexPreview.selectFieldsPopoverTitle": "选择字段", - "xpack.transform.sourceIndexPreview.SourceIndexArrayBadgeContent": "数组", - "xpack.transform.sourceIndexPreview.SourceIndexArrayToolTipContent": "此基于数组的列的完整内容在展开的行中。", "xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutBody": "源索引的查询未返回结果。请确保索引包含文档且您的查询限制不过于严格。", "xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutTitle": "源索引查询结果为空。", - "xpack.transform.sourceIndexPreview.SourceIndexObjectBadgeContent": "对象", - "xpack.transform.sourceIndexPreview.SourceIndexObjectToolTipContent": "此基于对象的列的完整内容在展开的行中。", "xpack.transform.sourceIndexPreview.sourceIndexPatternError": "加载源索引数据时出错。", "xpack.transform.sourceIndexPreview.sourceIndexPatternTitle": "源索引 {indexPatternTitle}", "xpack.transform.statsBar.batchTransformsLabel": "批量", @@ -12689,11 +12680,6 @@ "xpack.uptime.kueryBar.indexPatternMissingWarningMessage": "检索索引模式时出错。", "xpack.uptime.kueryBar.searchPlaceholder": "搜索监测 ID、名称和协议类型......", "xpack.uptime.locationName.helpLinkAnnotation": "添加位置", - "xpack.uptime.monitorChart.checksChart.bottomAxis.title": "鏃堕棿鎴", - "xpack.uptime.monitorChart.checksChart.leftAxis.title": "检查数目", - "xpack.uptime.monitorCharts.checkStatus.series.downCountLabel": "关闭计数", - "xpack.uptime.monitorCharts.checkStatus.series.upCountLabel": "运行计数", - "xpack.uptime.monitorCharts.checkStatus.title": "检查状态", "xpack.uptime.monitorCharts.durationChart.bottomAxis.title": "鏃堕棿鎴", "xpack.uptime.monitorCharts.durationChart.leftAxis.title": "持续时间 (ms)", "xpack.uptime.monitorCharts.loadingMessage": "正在加载……", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index f7d2b8f60157f..a34a032f833b2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -46,8 +46,6 @@ const DEFAULT_VALUES = { THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, TIME_WINDOW_SIZE: 5, TIME_WINDOW_UNIT: 'm', - TRIGGER_INTERVAL_SIZE: 1, - TRIGGER_INTERVAL_UNIT: 'm', THRESHOLD: [1000, 5000], GROUP_BY: 'all', }; @@ -141,7 +139,6 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent
diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts index 9c1a58760be79..8a6e89009b850 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts @@ -25,8 +25,8 @@ const INTERVAL_MINUTES = 1; const INTERVAL_DURATION = `${INTERVAL_MINUTES}m`; const INTERVAL_MILLIS = INTERVAL_MINUTES * 60 * 1000; -const WINDOW_MINUTES = 5; -const WINDOW_DURATION = `${WINDOW_MINUTES}m`; +const WINDOW_DURATION_SIZE = 5; +const WINDOW_DURATION_UNITS = 'm'; // interesting dates pertaining to docs and intervals const START_DATE_PLUS_YEAR = `2021-${START_DATE_MM_DD_HH_MM_SS_MS}`; @@ -154,7 +154,7 @@ export default function queryDataEndpointTests({ getService }: FtrProviderContex it('should return correct count for all intervals, grouped', async () => { const query = getQueryBody({ - groupField: 'group', + termField: 'group', dateStart: START_DATE_MINUS_2INTERVALS, dateEnd: START_DATE_MINUS_0INTERVALS, }); @@ -185,9 +185,11 @@ export default function queryDataEndpointTests({ getService }: FtrProviderContex it('should return correct average for all intervals, grouped', async () => { const query = getQueryBody({ - aggType: 'average', + aggType: 'avg', aggField: 'testedValue', - groupField: 'group', + groupBy: 'top', + termField: 'group', + termSize: 100, dateStart: START_DATE_MINUS_2INTERVALS, dateEnd: START_DATE_MINUS_0INTERVALS, }); @@ -266,11 +268,13 @@ function getQueryBody(body: Partial = {}): TimeSeriesQuery { timeField: 'date', aggType: 'count', aggField: undefined, - groupField: undefined, - groupLimit: undefined, + groupBy: 'all', + termField: undefined, + termSize: undefined, dateStart: START_DATE_MINUS_0INTERVALS, dateEnd: undefined, - window: WINDOW_DURATION, + timeWindowSize: WINDOW_DURATION_SIZE, + timeWindowUnit: WINDOW_DURATION_UNITS, interval: INTERVAL_DURATION, }; return Object.assign({}, defaults, body); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 5b54bfdafdbdb..4d1300ffaad06 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -89,7 +89,7 @@ export default function({ getService }: FtrProviderContext) { progress: '100', }, sourcePreview: { - columns: 6, + columns: 45, rows: 5, }, }, diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts index 2f5f60e1573c8..bf501c65bc79b 100644 --- a/x-pack/test/functional/apps/transform/creation_saved_search.ts +++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts @@ -63,7 +63,7 @@ export default function({ getService }: FtrProviderContext) { }, sourceIndex: 'farequote', sourcePreview: { - column: 3, + column: 2, values: ['ASA'], }, }, diff --git a/x-pack/test/functional/services/transform_ui/wizard.ts b/x-pack/test/functional/services/transform_ui/wizard.ts index aca08f7083aa8..2d20f3617cf06 100644 --- a/x-pack/test/functional/services/transform_ui/wizard.ts +++ b/x-pack/test/functional/services/transform_ui/wizard.ts @@ -76,17 +76,17 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { await testSubjects.existOrFail(selector); }, - async parseEuiInMemoryTable(tableSubj: string) { + async parseEuiDataGrid(tableSubj: string) { const table = await testSubjects.find(`~${tableSubj}`); const $ = await table.parseDomContent(); const rows = []; // For each row, get the content of each cell and // add its values as an array to each row. - for (const tr of $.findTestSubjects(`~${tableSubj}Row`).toArray()) { + for (const tr of $.findTestSubjects(`~dataGridRow`).toArray()) { rows.push( $(tr) - .find('.euiTableCellContent') + .find('.euiDataGridRowCell__truncate') .toArray() .map(cell => $(cell) @@ -99,14 +99,14 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { return rows; }, - async assertEuiInMemoryTableColumnValues( + async assertEuiDataGridColumnValues( tableSubj: string, column: number, expectedColumnValues: string[] ) { await retry.tryForTime(2000, async () => { // get a 2D array of rows and cell values - const rows = await this.parseEuiInMemoryTable(tableSubj); + const rows = await this.parseEuiDataGrid(tableSubj); // reduce the rows data to an array of unique values in the specified column const uniqueColumnValues = rows @@ -119,7 +119,7 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { // check if the returned unique value matches the supplied filter value expect(uniqueColumnValues).to.eql( expectedColumnValues, - `Unique EuiInMemoryTable column values should be '${expectedColumnValues.join()}' (got ${uniqueColumnValues.join()})` + `Unique EuiDataGrid column values should be '${expectedColumnValues.join()}' (got ${uniqueColumnValues.join()})` ); }); }, @@ -127,28 +127,28 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { async assertSourceIndexPreview(columns: number, rows: number) { await retry.tryForTime(2000, async () => { // get a 2D array of rows and cell values - const rowsData = await this.parseEuiInMemoryTable('transformSourceIndexPreview'); + const rowsData = await this.parseEuiDataGrid('transformSourceIndexPreview'); expect(rowsData).to.length( rows, - `EuiInMemoryTable rows should be ${rows} (got ${rowsData.length})` + `EuiDataGrid rows should be ${rows} (got ${rowsData.length})` ); rowsData.map((r, i) => expect(r).to.length( columns, - `EuiInMemoryTable row #${i + 1} column count should be ${columns} (got ${r.length})` + `EuiDataGrid row #${i + 1} column count should be ${columns} (got ${r.length})` ) ); }); }, async assertSourceIndexPreviewColumnValues(column: number, values: string[]) { - await this.assertEuiInMemoryTableColumnValues('transformSourceIndexPreview', column, values); + await this.assertEuiDataGridColumnValues('transformSourceIndexPreview', column, values); }, async assertPivotPreviewColumnValues(column: number, values: string[]) { - await this.assertEuiInMemoryTableColumnValues('transformPivotPreview', column, values); + await this.assertEuiDataGridColumnValues('transformPivotPreview', column, values); }, async assertPivotPreviewLoaded() { @@ -445,21 +445,25 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { }, async assertStartButtonExists() { - await testSubjects.existOrFail('transformWizardStartButton'); - expect(await testSubjects.isDisplayed('transformWizardStartButton')).to.eql( - true, - `Expected 'Start' button to be displayed` - ); + await retry.tryForTime(5000, async () => { + await testSubjects.existOrFail('transformWizardStartButton'); + expect(await testSubjects.isDisplayed('transformWizardStartButton')).to.eql( + true, + `Expected 'Start' button to be displayed` + ); + }); }, async assertStartButtonEnabled(expectedValue: boolean) { - const isEnabled = await testSubjects.isEnabled('transformWizardStartButton'); - expect(isEnabled).to.eql( - expectedValue, - `Expected 'Start' button to be '${expectedValue ? 'enabled' : 'disabled'}' (got ${ - isEnabled ? 'enabled' : 'disabled' - }')` - ); + await retry.tryForTime(5000, async () => { + const isEnabled = await testSubjects.isEnabled('transformWizardStartButton'); + expect(isEnabled).to.eql( + expectedValue, + `Expected 'Start' button to be '${expectedValue ? 'enabled' : 'disabled'}' (got ${ + isEnabled ? 'enabled' : 'disabled' + }')` + ); + }); }, async assertManagementCardExists() { @@ -492,17 +496,21 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { async createTransform() { await testSubjects.click('transformWizardCreateButton'); - await this.assertStartButtonExists(); - await this.assertStartButtonEnabled(true); - await this.assertManagementCardExists(); - await this.assertCreateButtonEnabled(false); + await retry.tryForTime(5000, async () => { + await this.assertStartButtonExists(); + await this.assertStartButtonEnabled(true); + await this.assertManagementCardExists(); + await this.assertCreateButtonEnabled(false); + }); }, async startTransform() { await testSubjects.click('transformWizardStartButton'); - await this.assertDiscoverCardExists(); - await this.assertStartButtonEnabled(false); - await this.assertProgressbarExists(); + await retry.tryForTime(5000, async () => { + await this.assertDiscoverCardExists(); + await this.assertStartButtonEnabled(false); + await this.assertProgressbarExists(); + }); }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 84081309c18d9..60ba03df6a9a8 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -45,16 +45,21 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should create an alert', async () => { const alertName = generateUniqueKey(); - await pageObjects.triggersActionsUI.clickCreateAlertButton(); - const nameInput = await testSubjects.find('alertNameInput'); await nameInput.click(); await nameInput.clearValue(); await nameInput.type(alertName); - - await testSubjects.click('threshold-SelectOption'); - + await testSubjects.click('.index-threshold-SelectOption'); + await testSubjects.click('selectIndexExpression'); + const comboBox = await find.byCssSelector('#indexSelectSearchBox'); + await comboBox.click(); + await comboBox.type('k'); + const filterSelectItem = await find.byCssSelector(`.euiFilterSelectItem`); + await filterSelectItem.click(); + await testSubjects.click('thresholdAlertTimeFieldSelect'); + const fieldOptions = await find.allByCssSelector('#thresholdTimeField option'); + await fieldOptions[1].click(); await testSubjects.click('.slack-ActionTypeSelectOption'); await testSubjects.click('createActionConnectorButton'); const connectorNameInput = await testSubjects.find('nameInput'); @@ -62,28 +67,32 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await connectorNameInput.clearValue(); const connectorName = generateUniqueKey(); await connectorNameInput.type(connectorName); - const slackWebhookUrlInput = await testSubjects.find('slackWebhookUrlInput'); await slackWebhookUrlInput.click(); await slackWebhookUrlInput.clearValue(); await slackWebhookUrlInput.type('https://test'); - await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); - const loggingMessageInput = await testSubjects.find('slackMessageTextArea'); await loggingMessageInput.click(); await loggingMessageInput.clearValue(); await loggingMessageInput.type('test message'); - - await testSubjects.click('slackAddVariableButton'); - const variableMenuButton = await testSubjects.find('variableMenuButton-0'); - await variableMenuButton.click(); - - await testSubjects.click('selectIndexExpression'); - - await find.clickByCssSelector('[data-test-subj="cancelSaveAlertButton"]'); - - // TODO: implement saving to the server, when threshold API will be ready + // TODO: uncomment variables test when server API will be ready + // await testSubjects.click('slackAddVariableButton'); + // const variableMenuButton = await testSubjects.find('variableMenuButton-0'); + // await variableMenuButton.click(); + await find.clickByCssSelector('[data-test-subj="saveAlertButton"]'); + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Saved '${alertName}'`); + await pageObjects.triggersActionsUI.searchAlerts(alertName); + const searchResultsAfterEdit = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResultsAfterEdit).to.eql([ + { + name: alertName, + tagsText: '', + alertType: 'Index Threshold', + interval: '1m', + }, + ]); }); it('should search for alert', async () => {