diff --git a/.ci/Jenkinsfile_coverage b/.ci/Jenkinsfile_coverage index e40cc584dc376..fa1e141be93ea 100644 --- a/.ci/Jenkinsfile_coverage +++ b/.ci/Jenkinsfile_coverage @@ -13,11 +13,7 @@ stage("Kibana Pipeline") { // This stage is just here to help the BlueOcean UI a ]) { parallel([ 'kibana-intake-agent': { - withEnv([ - 'NODE_ENV=test' // Needed for jest tests only - ]) { - kibanaPipeline.intakeWorker('kibana-intake', './test/scripts/jenkins_unit.sh')() - } + kibanaPipeline.intakeWorker('kibana-intake', './test/scripts/jenkins_unit.sh')() }, 'x-pack-intake-agent': { withEnv([ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 56db8d3793f57..bea10a1c8b31c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -75,6 +75,7 @@ /x-pack/plugins/ingest_manager/ @elastic/ingest /x-pack/legacy/plugins/ingest_manager/ @elastic/ingest /x-pack/plugins/observability/ @elastic/logs-metrics-ui @elastic/apm-ui @elastic/uptime @elastic/ingest +/x-pack/legacy/plugins/monitoring/ @elastic/stack-monitoring-ui # Machine Learning /x-pack/legacy/plugins/ml/ @elastic/ml-ui diff --git a/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana.test.yml b/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana.test.yml index 23f33940283c0..594c2efc8adc9 100644 --- a/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana.test.yml +++ b/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana.test.yml @@ -1,4 +1,5 @@ server: + autoListen: false port: 8274 logging: json: true @@ -6,3 +7,5 @@ optimize: enabled: false plugins: initialize: false +migrations: + skip: true diff --git a/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana_log_console.test.yml b/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana_log_console.test.yml new file mode 100644 index 0000000000000..33dd4787efad9 --- /dev/null +++ b/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana_log_console.test.yml @@ -0,0 +1,22 @@ +server: + autoListen: false + port: 8274 +logging: + loggers: + - context: root + appenders: + - console + level: debug + appenders: + console: + kind: console + layout: + kind: json + root: + level: debug +optimize: + enabled: false +plugins: + initialize: false +migrations: + skip: true diff --git a/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana_log_file.test.yml b/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana_log_file.test.yml new file mode 100644 index 0000000000000..f5148899ff854 --- /dev/null +++ b/src/cli/serve/integration_tests/__fixtures__/reload_logging_config/kibana_log_file.test.yml @@ -0,0 +1,22 @@ +server: + autoListen: false + port: 8274 +logging: + loggers: + - context: root + appenders: + - file + level: debug + appenders: + file: + kind: file + layout: + kind: pattern + root: + level: debug +optimize: + enabled: false +plugins: + initialize: false +migrations: + skip: true diff --git a/src/cli/serve/integration_tests/reload_logging_config.test.js b/src/cli/serve/integration_tests/reload_logging_config.test.js deleted file mode 100644 index 82d514877aff6..0000000000000 --- a/src/cli/serve/integration_tests/reload_logging_config.test.js +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import del from 'del'; - -import { safeDump } from 'js-yaml'; -import { - createMapStream, - createSplitStream, - createPromiseFromStreams, -} from '../../../legacy/utils/streams'; -import { getConfigFromFiles } from '../../../core/server/config/read_config'; - -const testConfigFile = follow('__fixtures__/reload_logging_config/kibana.test.yml'); -const kibanaPath = follow('../../../../scripts/kibana.js'); - -const second = 1000; -const minute = second * 60; - -const tempDir = path.join(os.tmpdir(), 'kbn-reload-test'); - -function follow(file) { - return path.relative(process.cwd(), path.resolve(__dirname, file)); -} - -function setLoggingJson(enabled) { - const conf = getConfigFromFiles([testConfigFile]); - conf.logging = conf.logging || {}; - conf.logging.json = enabled; - - const yaml = safeDump(conf); - - fs.writeFileSync(testConfigFile, yaml); -} - -describe('Server logging configuration', function() { - let child; - let isJson; - - beforeEach(() => { - isJson = true; - setLoggingJson(true); - - fs.mkdirSync(tempDir, { recursive: true }); - }); - - afterEach(() => { - isJson = true; - setLoggingJson(true); - - if (child !== undefined) { - child.kill(); - child = undefined; - } - - del.sync(tempDir, { force: true }); - }); - - const isWindows = /^win/.test(process.platform); - if (isWindows) { - it('SIGHUP is not a feature of Windows.', () => { - // nothing to do for Windows - }); - } else { - it( - 'should be reloadable via SIGHUP process signaling', - async function() { - expect.assertions(3); - - child = spawn( - process.execPath, - [kibanaPath, '--config', testConfigFile, '--oss', '--verbose'], - { - stdio: 'pipe', - } - ); - - let sawJson = false; - let sawNonjson = false; - - const [exitCode] = await Promise.all([ - Promise.race([ - new Promise(r => child.once('exit', r)).then(code => (code === null ? 0 : code)), - - new Promise(r => child.once('error', r)).then(err => { - throw new Error( - `error in child process while attempting to reload config. ${err.stack || - err.message || - err}` - ); - }), - ]), - - createPromiseFromStreams([ - child.stdout, - createSplitStream('\n'), - createMapStream(async line => { - if (!line) { - // skip empty lines - return; - } - - if (isJson) { - const data = JSON.parse(line); - sawJson = true; - - // We know the sighup handler will be registered before - // root.setup() is called - if (data.message.includes('setting up root')) { - isJson = false; - setLoggingJson(false); - - // Reload logging config. We give it a little bit of time to just make - // sure the process sighup handler is registered. - await new Promise(r => setTimeout(r, 100)); - child.kill('SIGHUP'); - } - } else if (line.startsWith('{')) { - // We have told Kibana to stop logging json, but it hasn't completed - // the switch yet, so we ignore before switching over. - } else { - // Kibana has successfully stopped logging json, so kill the server. - sawNonjson = true; - - child && child.kill(); - child = undefined; - } - }), - ]), - ]); - - expect(exitCode).toEqual(0); - expect(sawJson).toEqual(true); - expect(sawNonjson).toEqual(true); - }, - minute - ); - - it( - 'should recreate file handler on SIGHUP', - function(done) { - expect.hasAssertions(); - - const logPath = path.resolve(tempDir, 'kibana.log'); - const logPathArchived = path.resolve(tempDir, 'kibana_archive.log'); - - function watchFileUntil(path, matcher, timeout) { - return new Promise((resolve, reject) => { - const timeoutHandle = setTimeout(() => { - fs.unwatchFile(path); - reject(`watchFileUntil timed out for "${matcher}"`); - }, timeout); - - fs.watchFile(path, () => { - try { - const contents = fs.readFileSync(path); - - if (matcher.test(contents)) { - clearTimeout(timeoutHandle); - fs.unwatchFile(path); - resolve(contents); - } - } catch (e) { - // noop - } - }); - }); - } - - child = spawn(process.execPath, [ - kibanaPath, - '--oss', - '--config', - testConfigFile, - '--logging.dest', - logPath, - '--plugins.initialize', - 'false', - '--logging.json', - 'false', - '--verbose', - ]); - - watchFileUntil(logPath, /starting server/, 2 * minute) - .then(() => { - // once the server is running, archive the log file and issue SIGHUP - fs.renameSync(logPath, logPathArchived); - child.kill('SIGHUP'); - }) - .then(() => - watchFileUntil(logPath, /Reloaded logging configuration due to SIGHUP/, 10 * second) - ) - .then(contents => { - const lines = contents.toString().split('\n'); - // should be the first line of the new log file - expect(lines[0]).toMatch(/Reloaded logging configuration due to SIGHUP/); - child.kill(); - }) - .then(done, done); - }, - 3 * minute - ); - } -}); diff --git a/src/cli/serve/integration_tests/reload_logging_config.test.ts b/src/cli/serve/integration_tests/reload_logging_config.test.ts new file mode 100644 index 0000000000000..2def3569828d3 --- /dev/null +++ b/src/cli/serve/integration_tests/reload_logging_config.test.ts @@ -0,0 +1,263 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Child from 'child_process'; +import Fs from 'fs'; +import Path from 'path'; +import Os from 'os'; +import Del from 'del'; + +import * as Rx from 'rxjs'; +import { map, filter, take } from 'rxjs/operators'; +import { safeDump } from 'js-yaml'; + +import { getConfigFromFiles } from '../../../core/server/config/read_config'; + +const legacyConfig = follow('__fixtures__/reload_logging_config/kibana.test.yml'); +const configFileLogConsole = follow( + '__fixtures__/reload_logging_config/kibana_log_console.test.yml' +); +const configFileLogFile = follow('__fixtures__/reload_logging_config/kibana_log_file.test.yml'); + +const kibanaPath = follow('../../../../scripts/kibana.js'); + +const second = 1000; +const minute = second * 60; + +const tempDir = Path.join(Os.tmpdir(), 'kbn-reload-test'); + +function follow(file: string) { + return Path.relative(process.cwd(), Path.resolve(__dirname, file)); +} + +function watchFileUntil(path: string, matcher: RegExp, timeout: number) { + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + Fs.unwatchFile(path); + reject(`watchFileUntil timed out for "${matcher}"`); + }, timeout); + + Fs.watchFile(path, () => { + try { + const contents = Fs.readFileSync(path, 'utf-8'); + + if (matcher.test(contents)) { + clearTimeout(timeoutHandle); + Fs.unwatchFile(path); + resolve(contents); + } + } catch (e) { + // noop + } + }); + }); +} + +function containsJsonOnly(content: string[]) { + return content.every(line => line.startsWith('{')); +} + +function createConfigManager(configPath: string) { + return { + modify(fn: (input: Record) => Record) { + const oldContent = getConfigFromFiles([configPath]); + const yaml = safeDump(fn(oldContent)); + Fs.writeFileSync(configPath, yaml); + }, + }; +} + +describe('Server logging configuration', function() { + let child: Child.ChildProcess; + beforeEach(() => { + Fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + if (child !== undefined) { + child.kill(); + // wait for child to be killed otherwise jest complains that process not finished + await new Promise(res => setTimeout(res, 1000)); + } + Del.sync(tempDir, { force: true }); + }); + + const isWindows = /^win/.test(process.platform); + if (isWindows) { + it('SIGHUP is not a feature of Windows.', () => { + // nothing to do for Windows + }); + } else { + describe('legacy logging', () => { + it( + 'should be reloadable via SIGHUP process signaling', + async function() { + const configFilePath = Path.resolve(tempDir, 'kibana.yml'); + Fs.copyFileSync(legacyConfig, configFilePath); + + child = Child.spawn(process.execPath, [ + kibanaPath, + '--oss', + '--config', + configFilePath, + '--verbose', + ]); + + const message$ = Rx.fromEvent(child.stdout, 'data').pipe( + map(messages => + String(messages) + .split('\n') + .filter(Boolean) + ) + ); + + await message$ + .pipe( + // We know the sighup handler will be registered before this message logged + filter(messages => messages.some(m => m.includes('setting up root'))), + take(1) + ) + .toPromise(); + + const lastMessage = await message$.pipe(take(1)).toPromise(); + expect(containsJsonOnly(lastMessage)).toBe(true); + + createConfigManager(configFilePath).modify(oldConfig => { + oldConfig.logging.json = false; + return oldConfig; + }); + + child.kill('SIGHUP'); + + await message$ + .pipe( + filter(messages => !containsJsonOnly(messages)), + take(1) + ) + .toPromise(); + }, + minute + ); + + it( + 'should recreate file handle on SIGHUP', + async function() { + const logPath = Path.resolve(tempDir, 'kibana.log'); + const logPathArchived = Path.resolve(tempDir, 'kibana_archive.log'); + + child = Child.spawn(process.execPath, [ + kibanaPath, + '--oss', + '--config', + legacyConfig, + '--logging.dest', + logPath, + '--verbose', + ]); + + await watchFileUntil(logPath, /setting up root/, 30 * second); + // once the server is running, archive the log file and issue SIGHUP + Fs.renameSync(logPath, logPathArchived); + child.kill('SIGHUP'); + + await watchFileUntil( + logPath, + /Reloaded logging configuration due to SIGHUP/, + 30 * second + ); + }, + minute + ); + }); + + describe('platform logging', () => { + it( + 'should be reloadable via SIGHUP process signaling', + async function() { + const configFilePath = Path.resolve(tempDir, 'kibana.yml'); + Fs.copyFileSync(configFileLogConsole, configFilePath); + + child = Child.spawn(process.execPath, [kibanaPath, '--oss', '--config', configFilePath]); + + const message$ = Rx.fromEvent(child.stdout, 'data').pipe( + map(messages => + String(messages) + .split('\n') + .filter(Boolean) + ) + ); + + await message$ + .pipe( + // We know the sighup handler will be registered before this message logged + filter(messages => messages.some(m => m.includes('setting up root'))), + take(1) + ) + .toPromise(); + + const lastMessage = await message$.pipe(take(1)).toPromise(); + expect(containsJsonOnly(lastMessage)).toBe(true); + + createConfigManager(configFilePath).modify(oldConfig => { + oldConfig.logging.appenders.console.layout.kind = 'pattern'; + return oldConfig; + }); + child.kill('SIGHUP'); + + await message$ + .pipe( + filter(messages => !containsJsonOnly(messages)), + take(1) + ) + .toPromise(); + }, + 30 * second + ); + it( + 'should recreate file handle on SIGHUP', + async function() { + const configFilePath = Path.resolve(tempDir, 'kibana.yml'); + Fs.copyFileSync(configFileLogFile, configFilePath); + + const logPath = Path.resolve(tempDir, 'kibana.log'); + const logPathArchived = Path.resolve(tempDir, 'kibana_archive.log'); + + createConfigManager(configFilePath).modify(oldConfig => { + oldConfig.logging.appenders.file.path = logPath; + return oldConfig; + }); + + child = Child.spawn(process.execPath, [kibanaPath, '--oss', '--config', configFilePath]); + + await watchFileUntil(logPath, /setting up root/, 30 * second); + // once the server is running, archive the log file and issue SIGHUP + Fs.renameSync(logPath, logPathArchived); + child.kill('SIGHUP'); + + await watchFileUntil( + logPath, + /Reloaded logging configuration due to SIGHUP/, + 30 * second + ); + }, + minute + ); + }); + } +}); diff --git a/src/fixtures/fake_hierarchical_data.js b/src/fixtures/fake_hierarchical_data.ts similarity index 98% rename from src/fixtures/fake_hierarchical_data.js rename to src/fixtures/fake_hierarchical_data.ts index b4ae02a487049..4480caae39664 100644 --- a/src/fixtures/fake_hierarchical_data.js +++ b/src/fixtures/fake_hierarchical_data.ts @@ -17,16 +17,14 @@ * under the License. */ -const data = {}; - -data.metricOnly = { +export const metricOnly = { hits: { total: 1000, hits: [], max_score: 0 }, aggregations: { agg_1: { value: 412032 }, }, }; -data.threeTermBuckets = { +export const threeTermBuckets = { hits: { total: 1000, hits: [], max_score: 0 }, aggregations: { agg_2: { @@ -129,7 +127,7 @@ data.threeTermBuckets = { }, }; -data.oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = { +export const oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = { hits: { total: 1000, hits: [], max_score: 0 }, aggregations: { agg_3: { @@ -520,7 +518,7 @@ data.oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = { }, }; -data.oneRangeBucket = { +export const oneRangeBucket = { took: 35, timed_out: false, _shards: { @@ -555,7 +553,7 @@ data.oneRangeBucket = { }, }; -data.oneFilterBucket = { +export const oneFilterBucket = { took: 11, timed_out: false, _shards: { @@ -582,7 +580,7 @@ data.oneFilterBucket = { }, }; -data.oneHistogramBucket = { +export const oneHistogramBucket = { took: 37, timed_out: false, _shards: { @@ -632,5 +630,3 @@ data.oneHistogramBucket = { }, }, }; - -export default data; diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts index 50120292a627a..ce46f534141f4 100644 --- a/src/legacy/core_plugins/data/public/index.ts +++ b/src/legacy/core_plugins/data/public/index.ts @@ -81,4 +81,6 @@ export { // search_source getRequestInspectorStats, getResponseInspectorStats, + tabifyAggResponse, + tabifyGetColumns, } from './search'; diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_configs.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_configs.ts index 7e7e4944b00da..8e091ed5f21ae 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_configs.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_configs.ts @@ -27,7 +27,7 @@ */ import _ from 'lodash'; -import { AggConfig, AggConfigOptions } from './agg_config'; +import { AggConfig, AggConfigOptions, IAggConfig } from './agg_config'; import { Schema } from './schemas'; import { AggGroupNames } from './agg_groups'; import { @@ -63,7 +63,7 @@ export class AggConfigs { public schemas: any; public timeRange?: TimeRange; - aggs: AggConfig[]; + aggs: IAggConfig[]; constructor(indexPattern: IndexPattern, configStates = [] as any, schemas?: any) { configStates = AggConfig.ensureIds(configStates); @@ -74,7 +74,7 @@ export class AggConfigs { configStates.forEach((params: any) => this.createAggConfig(params)); - if (this.schemas) { + if (schemas) { this.initializeDefaultsFromSchemas(schemas); } } diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_params.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_params.ts index 34727ff4614b9..551cb81529a0a 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_params.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_params.ts @@ -76,7 +76,9 @@ export const writeParams = < aggs?: IAggConfigs, locals?: Record ) => { - const output = { params: {} as Record }; + const output: Record = { + params: {} as Record, + }; locals = locals || {}; params.forEach(param => { diff --git a/src/legacy/core_plugins/data/public/search/aggs/index.ts b/src/legacy/core_plugins/data/public/search/aggs/index.ts index 0fef7f38aae74..0bdb92b8de65e 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/index.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/index.ts @@ -50,3 +50,6 @@ export { isValidJson, isValidInterval } from './utils'; export { BUCKET_TYPES } from './buckets/bucket_agg_types'; export { METRIC_TYPES } from './metrics/metric_agg_types'; export { ISchemas, Schema, Schemas } from './schemas'; + +// types +export { IAggConfig, IAggConfigs } from './types'; diff --git a/src/legacy/core_plugins/data/public/search/expressions/esaggs.ts b/src/legacy/core_plugins/data/public/search/expressions/esaggs.ts index 9aee7124c9521..302527e4ed549 100644 --- a/src/legacy/core_plugins/data/public/search/expressions/esaggs.ts +++ b/src/legacy/core_plugins/data/public/search/expressions/esaggs.ts @@ -39,8 +39,7 @@ import { import { buildTabularInspectorData } from './build_tabular_inspector_data'; import { calculateObjectHash } from '../../../../visualizations/public'; -// @ts-ignore -import { tabifyAggResponse } from '../../../../../ui/public/agg_response/tabify/tabify'; +import { tabifyAggResponse } from '../../../../../core_plugins/data/public'; import { PersistedState } from '../../../../../ui/public/persisted_state'; import { Adapters } from '../../../../../../plugins/inspector/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths diff --git a/src/legacy/core_plugins/data/public/search/index.ts b/src/legacy/core_plugins/data/public/search/index.ts index 90e191b769a8d..96d2825559da2 100644 --- a/src/legacy/core_plugins/data/public/search/index.ts +++ b/src/legacy/core_plugins/data/public/search/index.ts @@ -20,3 +20,4 @@ export * from './aggs'; export { getRequestInspectorStats, getResponseInspectorStats } from './utils'; export { serializeAggConfig } from './expressions/utils'; +export { tabifyAggResponse, tabifyGetColumns } from './tabify'; diff --git a/src/legacy/ui/public/agg_response/tabify/__tests__/_buckets.js b/src/legacy/core_plugins/data/public/search/tabify/buckets.test.ts similarity index 66% rename from src/legacy/ui/public/agg_response/tabify/__tests__/_buckets.js rename to src/legacy/core_plugins/data/public/search/tabify/buckets.test.ts index b85b45d3c5820..ef2748102623a 100644 --- a/src/legacy/ui/public/agg_response/tabify/__tests__/_buckets.js +++ b/src/legacy/core_plugins/data/public/search/tabify/buckets.test.ts @@ -17,31 +17,36 @@ * under the License. */ -import expect from '@kbn/expect'; -import { TabifyBuckets } from '../_buckets'; +import { TabifyBuckets } from './buckets'; +import { AggGroupNames } from '../aggs'; -describe('Buckets wrapper', function() { - function test(aggResp, count, keys) { - it('reads the length', function() { +jest.mock('ui/new_platform'); + +describe('Buckets wrapper', () => { + const check = (aggResp: any, count: number, keys: string[]) => { + test('reads the length', () => { const buckets = new TabifyBuckets(aggResp); - expect(buckets).to.have.length(count); + expect(buckets).toHaveLength(count); }); - it('iterates properly, passing in the key', function() { + test('iterates properly, passing in the key', () => { const buckets = new TabifyBuckets(aggResp); - const keysSent = []; - buckets.forEach(function(bucket, key) { - keysSent.push(key); + const keysSent: any[] = []; + + buckets.forEach((bucket, key) => { + if (key) { + keysSent.push(key); + } }); - expect(keysSent).to.have.length(count); - expect(keysSent).to.eql(keys); + expect(keysSent).toHaveLength(count); + expect(keysSent).toEqual(keys); }); - } + }; - describe('with object style buckets', function() { - const aggResp = { - buckets: { + describe('with object style buckets', () => { + let aggResp: any = { + [AggGroupNames.Buckets]: { '0-100': {}, '100-200': {}, '200-300': {}, @@ -51,11 +56,11 @@ describe('Buckets wrapper', function() { const count = 3; const keys = ['0-100', '100-200', '200-300']; - test(aggResp, count, keys); + check(aggResp, count, keys); - it('should accept filters agg queries with strings', () => { - const aggResp = { - buckets: { + test('should accept filters agg queries with strings', () => { + aggResp = { + [AggGroupNames.Buckets]: { 'response:200': {}, 'response:404': {}, }, @@ -75,15 +80,17 @@ describe('Buckets wrapper', function() { }; const buckets = new TabifyBuckets(aggResp, aggParams); - expect(buckets).to.have.length(2); + + expect(buckets).toHaveLength(2); + buckets._keys.forEach(key => { - expect(key).to.be.a('string'); + expect(typeof key).toBe('string'); }); }); - it('should accept filters agg queries with query_string queries', () => { - const aggResp = { - buckets: { + test('should accept filters agg queries with query_string queries', () => { + aggResp = { + [AggGroupNames.Buckets]: { 'response:200': {}, 'response:404': {}, }, @@ -103,15 +110,17 @@ describe('Buckets wrapper', function() { }; const buckets = new TabifyBuckets(aggResp, aggParams); - expect(buckets).to.have.length(2); + + expect(buckets).toHaveLength(2); + buckets._keys.forEach(key => { - expect(key).to.be.a('string'); + expect(typeof key).toBe('string'); }); }); - it('should accept filters agg queries with query dsl queries', () => { - const aggResp = { - buckets: { + test('should accept filters agg queries with query dsl queries', () => { + aggResp = { + [AggGroupNames.Buckets]: { '{match_all: {}}': {}, }, }; @@ -126,16 +135,18 @@ describe('Buckets wrapper', function() { }; const buckets = new TabifyBuckets(aggResp, aggParams); - expect(buckets).to.have.length(1); + + expect(buckets).toHaveLength(1); + buckets._keys.forEach(key => { - expect(key).to.be.a('string'); + expect(typeof key).toBe('string'); }); }); }); - describe('with array style buckets', function() { + describe('with array style buckets', () => { const aggResp = { - buckets: [ + [AggGroupNames.Buckets]: [ { key: '0-100', value: {} }, { key: '100-200', value: {} }, { key: '200-300', value: {} }, @@ -145,23 +156,24 @@ describe('Buckets wrapper', function() { const count = 3; const keys = ['0-100', '100-200', '200-300']; - test(aggResp, count, keys); + check(aggResp, count, keys); }); - describe('with single bucket aggregations (filter)', function() { - it('creates single bucket from agg content', function() { + describe('with single bucket aggregations (filter)', () => { + test('creates single bucket from agg content', () => { const aggResp = { single_bucket: {}, doc_count: 5, }; const buckets = new TabifyBuckets(aggResp); - expect(buckets).to.have.length(1); + + expect(buckets).toHaveLength(1); }); }); - describe('drop_partial option', function() { + describe('drop_partial option', () => { const aggResp = { - buckets: [ + [AggGroupNames.Buckets]: [ { key: 0, value: {} }, { key: 100, value: {} }, { key: 200, value: {} }, @@ -169,7 +181,7 @@ describe('Buckets wrapper', function() { ], }; - it('drops partial buckets when enabled', function() { + test('drops partial buckets when enabled', () => { const aggParams = { drop_partials: true, field: { @@ -182,10 +194,11 @@ describe('Buckets wrapper', function() { name: 'date', }; const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); - expect(buckets).to.have.length(1); + + expect(buckets).toHaveLength(1); }); - it('keeps partial buckets when disabled', function() { + test('keeps partial buckets when disabled', () => { const aggParams = { drop_partials: false, field: { @@ -198,10 +211,11 @@ describe('Buckets wrapper', function() { name: 'date', }; const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); - expect(buckets).to.have.length(4); + + expect(buckets).toHaveLength(4); }); - it('keeps aligned buckets when enabled', function() { + test('keeps aligned buckets when enabled', () => { const aggParams = { drop_partials: true, field: { @@ -214,10 +228,11 @@ describe('Buckets wrapper', function() { name: 'date', }; const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); - expect(buckets).to.have.length(3); + + expect(buckets).toHaveLength(3); }); - it('does not drop buckets for non-timerange fields', function() { + test('does not drop buckets for non-timerange fields', () => { const aggParams = { drop_partials: true, field: { @@ -230,7 +245,8 @@ describe('Buckets wrapper', function() { name: 'date', }; const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); - expect(buckets).to.have.length(4); + + expect(buckets).toHaveLength(4); }); }); }); diff --git a/src/legacy/core_plugins/data/public/search/tabify/buckets.ts b/src/legacy/core_plugins/data/public/search/tabify/buckets.ts new file mode 100644 index 0000000000000..8078136299f8c --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/tabify/buckets.ts @@ -0,0 +1,135 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get, isPlainObject, keys, findKey } from 'lodash'; +import moment from 'moment'; +import { IAggConfig } from '../aggs'; +import { TabbedRangeFilterParams } from './types'; +import { AggResponseBucket } from '../types'; + +type AggParams = IAggConfig['params'] & { + drop_partials: boolean; + ranges: TabbedRangeFilterParams[]; +}; + +const isRangeEqual = (range1: TabbedRangeFilterParams, range2: TabbedRangeFilterParams) => + range1?.from === range2?.from && range1?.to === range2?.to; + +export class TabifyBuckets { + length: number; + objectMode: boolean; + buckets: any; + _keys: any[] = []; + + constructor(aggResp: any, aggParams?: AggParams, timeRange?: TabbedRangeFilterParams) { + if (aggResp && aggResp.buckets) { + this.buckets = aggResp.buckets; + } else if (aggResp) { + // Some Bucket Aggs only return a single bucket (like filter). + // In those instances, the aggResp is the content of the single bucket. + this.buckets = [aggResp]; + } else { + this.buckets = []; + } + + this.objectMode = isPlainObject(this.buckets); + + if (this.objectMode) { + this._keys = keys(this.buckets); + this.length = this._keys.length; + } else { + this.length = this.buckets.length; + } + + if (this.length && aggParams) { + this.orderBucketsAccordingToParams(aggParams); + if (aggParams.drop_partials) { + this.dropPartials(aggParams, timeRange); + } + } + } + + forEach(fn: (bucket: any, key: any) => void) { + const buckets = this.buckets; + + if (this.objectMode) { + this._keys.forEach(key => { + fn(buckets[key], key); + }); + } else { + buckets.forEach((bucket: AggResponseBucket) => { + fn(bucket, bucket.key); + }); + } + } + + private orderBucketsAccordingToParams(params: AggParams) { + if (params.filters && this.objectMode) { + this._keys = params.filters.map((filter: any) => { + const query = get(filter, 'input.query.query_string.query', filter.input.query); + const queryString = typeof query === 'string' ? query : JSON.stringify(query); + + return filter.label || queryString || '*'; + }); + } else if (params.ranges && this.objectMode) { + this._keys = params.ranges.map((range: TabbedRangeFilterParams) => + findKey(this.buckets, (el: TabbedRangeFilterParams) => isRangeEqual(el, range)) + ); + } else if (params.ranges && params.field.type !== 'date') { + let ranges = params.ranges; + if (params.ipRangeType) { + ranges = params.ipRangeType === 'mask' ? ranges.mask : ranges.fromTo; + } + this.buckets = ranges.map((range: any) => { + if (range.mask) { + return this.buckets.find((el: AggResponseBucket) => el.key === range.mask); + } + + return this.buckets.find((el: TabbedRangeFilterParams) => isRangeEqual(el, range)); + }); + } + } + + // dropPartials should only be called if the aggParam setting is enabled, + // and the agg field is the same as the Time Range. + private dropPartials(params: AggParams, timeRange?: TabbedRangeFilterParams) { + if ( + !timeRange || + this.buckets.length <= 1 || + this.objectMode || + params.field.name !== timeRange.name + ) { + return; + } + + const interval = this.buckets[1].key - this.buckets[0].key; + + this.buckets = this.buckets.filter((bucket: AggResponseBucket) => { + if (moment(bucket.key).isBefore(timeRange.gte)) { + return false; + } + if (moment(bucket.key + interval).isAfter(timeRange.lte)) { + return false; + } + return true; + }); + + this.length = this.buckets.length; + } +} diff --git a/src/legacy/core_plugins/data/public/search/tabify/get_columns.test.ts b/src/legacy/core_plugins/data/public/search/tabify/get_columns.test.ts new file mode 100644 index 0000000000000..0328e87d8b832 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/tabify/get_columns.test.ts @@ -0,0 +1,191 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { tabifyGetColumns, AggColumn } from './get_columns'; +import { AggConfigs, AggGroupNames, Schemas } from '../aggs'; + +jest.mock('ui/new_platform'); + +describe('get columns', () => { + const createAggConfigs = (aggs: any[] = []) => { + const field = { + name: '@timestamp', + }; + + const indexPattern = { + id: '1234', + title: 'logstash-*', + fields: { + getByName: () => field, + filter: () => [field], + }, + } as any; + + return new AggConfigs( + indexPattern, + aggs, + new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + min: 1, + defaults: [{ schema: 'metric', type: 'count' }], + }, + ]).all + ); + }; + + test('should inject a count metric if no aggs exist', () => { + const columns = tabifyGetColumns(createAggConfigs().aggs, true); + + expect(columns).toHaveLength(1); + expect(columns[0]).toHaveProperty('aggConfig'); + expect(columns[0].aggConfig.type).toHaveProperty('name', 'count'); + }); + + test('should inject a count metric if only buckets exist', () => { + const columns = tabifyGetColumns( + createAggConfigs([ + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + ]).aggs, + true + ); + + expect(columns).toHaveLength(2); + expect(columns[1]).toHaveProperty('aggConfig'); + expect(columns[1].aggConfig.type).toHaveProperty('name', 'count'); + }); + + test('should inject the metric after each bucket if the vis is hierarchical', () => { + const columns = tabifyGetColumns( + createAggConfigs([ + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + ]).aggs, + false + ); + + expect(columns).toHaveLength(8); + + columns.forEach((column, i) => { + expect(column).toHaveProperty('aggConfig'); + expect(column.aggConfig.type).toHaveProperty('name', i % 2 ? 'count' : 'date_histogram'); + }); + }); + + test('should inject the multiple metrics after each bucket if the vis is hierarchical', () => { + const columns = tabifyGetColumns( + createAggConfigs([ + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + { type: 'sum', schema: 'metric', params: { field: 'bytes' } }, + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + ]).aggs, + false + ); + + function checkColumns(column: AggColumn, i: number) { + expect(column).toHaveProperty('aggConfig'); + + switch (i) { + case 0: + expect(column.aggConfig.type).toHaveProperty('name', 'date_histogram'); + break; + case 1: + expect(column.aggConfig.type).toHaveProperty('name', 'avg'); + break; + case 2: + expect(column.aggConfig.type).toHaveProperty('name', 'sum'); + break; + } + } + + expect(columns).toHaveLength(12); + + for (let i = 0; i < columns.length; i += 3) { + columns.slice(i, i + 3).forEach(checkColumns); + } + }); + + test('should put all metrics at the end of the columns if the vis is not hierarchical', () => { + const columns = tabifyGetColumns( + createAggConfigs([ + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '20s' }, + }, + { type: 'sum', schema: 'metric', params: { field: '@timestamp' } }, + { + type: 'date_histogram', + schema: 'segment', + params: { field: '@timestamp', interval: '10s' }, + }, + ]).aggs, + false + ); + + expect(columns.map(c => c.name)).toEqual([ + '@timestamp per 20 seconds', + 'Sum of @timestamp', + '@timestamp per 10 seconds', + 'Sum of @timestamp', + ]); + }); +}); diff --git a/src/legacy/ui/public/agg_response/tabify/_get_columns.ts b/src/legacy/core_plugins/data/public/search/tabify/get_columns.ts similarity index 96% rename from src/legacy/ui/public/agg_response/tabify/_get_columns.ts rename to src/legacy/core_plugins/data/public/search/tabify/get_columns.ts index 4144d5be16012..54f09f6c6364f 100644 --- a/src/legacy/ui/public/agg_response/tabify/_get_columns.ts +++ b/src/legacy/core_plugins/data/public/search/tabify/get_columns.ts @@ -18,7 +18,7 @@ */ import { groupBy } from 'lodash'; -import { IAggConfig } from '../../agg_types'; +import { IAggConfig } from '../aggs'; export interface AggColumn { aggConfig: IAggConfig; @@ -40,7 +40,7 @@ const getColumn = (agg: IAggConfig, i: number): AggColumn => { * @param {AggConfigs} aggs - the agg configs object to which the aggregation response correlates * @param {boolean} minimalColumns - setting to true will only return a column for the last bucket/metric instead of one for each level */ -export function tabifyGetColumns(aggs: IAggConfig[], minimalColumns: boolean) { +export function tabifyGetColumns(aggs: IAggConfig[], minimalColumns: boolean): AggColumn[] { // pick the columns if (minimalColumns) { return aggs.map((agg, i) => getColumn(agg, i)); diff --git a/src/legacy/ui/public/agg_response/tabify/index.js b/src/legacy/core_plugins/data/public/search/tabify/index.ts similarity index 94% rename from src/legacy/ui/public/agg_response/tabify/index.js rename to src/legacy/core_plugins/data/public/search/tabify/index.ts index f14ca647e4b32..be8d64510033c 100644 --- a/src/legacy/ui/public/agg_response/tabify/index.js +++ b/src/legacy/core_plugins/data/public/search/tabify/index.ts @@ -18,3 +18,4 @@ */ export { tabifyAggResponse } from './tabify'; +export { tabifyGetColumns } from './get_columns'; diff --git a/src/legacy/core_plugins/data/public/search/tabify/response_writer.test.ts b/src/legacy/core_plugins/data/public/search/tabify/response_writer.test.ts new file mode 100644 index 0000000000000..f5df0a683ca00 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/tabify/response_writer.test.ts @@ -0,0 +1,170 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TabbedAggResponseWriter } from './response_writer'; +import { AggConfigs, AggGroupNames, Schemas, BUCKET_TYPES } from '../aggs'; + +import { TabbedResponseWriterOptions } from './types'; + +jest.mock('ui/new_platform'); + +describe('TabbedAggResponseWriter class', () => { + let responseWriter: TabbedAggResponseWriter; + + const splitAggConfig = [ + { + type: BUCKET_TYPES.TERMS, + params: { + field: 'geo.src', + }, + }, + ]; + + const twoSplitsAggConfig = [ + { + type: BUCKET_TYPES.TERMS, + params: { + field: 'geo.src', + }, + }, + { + type: BUCKET_TYPES.TERMS, + params: { + field: 'machine.os.raw', + }, + }, + ]; + + const createResponseWritter = (aggs: any[] = [], opts?: Partial) => { + const field = { + name: 'geo.src', + }; + + const indexPattern = { + id: '1234', + title: 'logstash-*', + fields: { + getByName: () => field, + filter: () => [field], + }, + } as any; + + return new TabbedAggResponseWriter( + new AggConfigs( + indexPattern, + aggs, + new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + min: 1, + defaults: [{ schema: 'metric', type: 'count' }], + }, + ]).all + ), + { + metricsAtAllLevels: false, + partialRows: false, + ...opts, + } + ); + }; + + describe('Constructor', () => { + beforeEach(() => { + responseWriter = createResponseWritter(twoSplitsAggConfig); + }); + + test('generates columns', () => { + expect(responseWriter.columns.length).toEqual(3); + }); + + test('correctly generates columns with metricsAtAllLevels set to true', () => { + const minimalColumnsResponseWriter = createResponseWritter(twoSplitsAggConfig, { + metricsAtAllLevels: true, + }); + + expect(minimalColumnsResponseWriter.columns.length).toEqual(4); + }); + + describe('row()', () => { + beforeEach(() => { + responseWriter = createResponseWritter(splitAggConfig); + }); + + test('adds the row to the array', () => { + responseWriter.bucketBuffer = [{ id: 'col-0', value: 'US' }]; + responseWriter.metricBuffer = [{ id: 'col-1', value: 5 }]; + + responseWriter.row(); + + expect(responseWriter.rows.length).toEqual(1); + expect(responseWriter.rows[0]).toEqual({ 'col-0': 'US', 'col-1': 5 }); + }); + + test("doesn't add an empty row", () => { + responseWriter.row(); + + expect(responseWriter.rows.length).toEqual(0); + }); + }); + + describe('response()', () => { + beforeEach(() => { + responseWriter = createResponseWritter(splitAggConfig); + }); + + test('produces correct response', () => { + responseWriter.bucketBuffer = [ + { id: 'col-0-1', value: 'US' }, + { id: 'col-1-2', value: 5 }, + ]; + responseWriter.row(); + + const response = responseWriter.response(); + + expect(response).toHaveProperty('rows'); + expect(response.rows).toEqual([{ 'col-0-1': 'US', 'col-1-2': 5 }]); + expect(response).toHaveProperty('columns'); + expect(response.columns.length).toEqual(2); + expect(response.columns[0]).toHaveProperty('id', 'col-0-1'); + expect(response.columns[0]).toHaveProperty('name', 'geo.src: Descending'); + expect(response.columns[0]).toHaveProperty('aggConfig'); + expect(response.columns[1]).toHaveProperty('id', 'col-1-2'); + expect(response.columns[1]).toHaveProperty('name', 'Count'); + expect(response.columns[1]).toHaveProperty('aggConfig'); + }); + + test('produces correct response for no data', () => { + const response = responseWriter.response(); + + expect(response).toHaveProperty('rows'); + expect(response.rows.length).toBe(0); + expect(response).toHaveProperty('columns'); + expect(response.columns.length).toEqual(2); + expect(response.columns[0]).toHaveProperty('id', 'col-0-1'); + expect(response.columns[0]).toHaveProperty('name', 'geo.src: Descending'); + expect(response.columns[0]).toHaveProperty('aggConfig'); + expect(response.columns[1]).toHaveProperty('id', 'col-1-2'); + expect(response.columns[1]).toHaveProperty('name', 'Count'); + expect(response.columns[1]).toHaveProperty('aggConfig'); + }); + }); + }); +}); diff --git a/src/legacy/core_plugins/data/public/search/tabify/response_writer.ts b/src/legacy/core_plugins/data/public/search/tabify/response_writer.ts new file mode 100644 index 0000000000000..4c4578e505b71 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/tabify/response_writer.ts @@ -0,0 +1,88 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isEmpty } from 'lodash'; +import { IAggConfigs } from '../aggs/agg_configs'; +import { AggColumn, tabifyGetColumns } from './get_columns'; + +import { TabbedResponseWriterOptions } from './types'; + +interface TabbedAggColumn { + id: string; + value: string | number; +} + +type TabbedAggRow = Record; + +/** + * Writer class that collects information about an aggregation response and + * produces a table, or a series of tables. + */ +export class TabbedAggResponseWriter { + columns: AggColumn[]; + rows: TabbedAggRow[] = []; + bucketBuffer: TabbedAggColumn[] = []; + metricBuffer: TabbedAggColumn[] = []; + + private readonly partialRows: boolean; + + /** + * @param {AggConfigs} aggs - the agg configs object to which the aggregation response correlates + * @param {boolean} metricsAtAllLevels - setting to true will produce metrics for every bucket + * @param {boolean} partialRows - setting to true will not remove rows with missing values + */ + constructor( + aggs: IAggConfigs, + { metricsAtAllLevels = false, partialRows = false }: Partial + ) { + this.partialRows = partialRows; + + this.columns = tabifyGetColumns(aggs.getResponseAggs(), !metricsAtAllLevels); + this.rows = []; + } + + /** + * Create a new row by reading the row buffer and bucketBuffer + */ + row() { + const rowBuffer: TabbedAggRow = {}; + + this.bucketBuffer.forEach(bucket => { + rowBuffer[bucket.id] = bucket.value; + }); + + this.metricBuffer.forEach(metric => { + rowBuffer[metric.id] = metric.value; + }); + + const isPartialRow = + this.partialRows && !this.columns.every(column => rowBuffer.hasOwnProperty(column.id)); + + if (!isEmpty(rowBuffer) && !isPartialRow) { + this.rows.push(rowBuffer); + } + } + + response() { + return { + columns: this.columns, + rows: this.rows, + }; + } +} diff --git a/src/legacy/core_plugins/data/public/search/tabify/tabify.test.ts b/src/legacy/core_plugins/data/public/search/tabify/tabify.test.ts new file mode 100644 index 0000000000000..13fe7719b0a85 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/tabify/tabify.test.ts @@ -0,0 +1,172 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPattern } from '../../../../../../plugins/data/public'; +import { tabifyAggResponse } from './tabify'; +import { IAggConfig, IAggConfigs, AggGroupNames, Schemas, AggConfigs } from '../aggs'; +import { metricOnly, threeTermBuckets } from 'fixtures/fake_hierarchical_data'; + +jest.mock('ui/new_platform'); + +describe('tabifyAggResponse Integration', () => { + const createAggConfigs = (aggs: IAggConfig[] = []) => { + const field = { + name: '@timestamp', + }; + + const indexPattern = ({ + id: '1234', + title: 'logstash-*', + fields: { + getByName: () => field, + filter: () => [field], + }, + } as unknown) as IndexPattern; + + return new AggConfigs( + indexPattern, + aggs, + new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + min: 1, + defaults: [{ schema: 'metric', type: 'count' }], + }, + ]).all + ); + }; + + const mockAggConfig = (agg: any): IAggConfig => (agg as unknown) as IAggConfig; + + test('transforms a simple response properly', () => { + const aggConfigs = createAggConfigs(); + + const resp = tabifyAggResponse(aggConfigs, metricOnly, { + metricsAtAllLevels: true, + }); + + expect(resp).toHaveProperty('rows'); + expect(resp).toHaveProperty('columns'); + + expect(resp.rows).toHaveLength(1); + expect(resp.columns).toHaveLength(1); + + expect(resp.rows[0]).toEqual({ 'col-0-1': 1000 }); + expect(resp.columns[0]).toHaveProperty('aggConfig', aggConfigs.aggs[0]); + }); + + describe('transforms a complex response', () => { + let esResp: typeof threeTermBuckets; + let aggConfigs: IAggConfigs; + let avg: IAggConfig; + let ext: IAggConfig; + let src: IAggConfig; + let os: IAggConfig; + + beforeEach(() => { + aggConfigs = createAggConfigs([ + mockAggConfig({ type: 'avg', schema: 'metric', params: { field: '@timestamp' } }), + mockAggConfig({ type: 'terms', schema: 'split', params: { field: '@timestamp' } }), + mockAggConfig({ type: 'terms', schema: 'segment', params: { field: '@timestamp' } }), + mockAggConfig({ type: 'terms', schema: 'segment', params: { field: '@timestamp' } }), + ]); + + [avg, ext, src, os] = aggConfigs.aggs; + + esResp = threeTermBuckets; + esResp.aggregations.agg_2.buckets[1].agg_3.buckets[0].agg_4.buckets = []; + }); + + // check that the columns of a table are formed properly + function expectColumns(table: ReturnType, aggs: IAggConfig[]) { + expect(table.columns).toHaveLength(aggs.length); + + aggs.forEach((agg, i) => { + expect(table.columns[i]).toHaveProperty('aggConfig', agg); + }); + } + + // check that a row has expected values + function expectRow( + row: Record, + asserts: Array<(val: string | number) => void> + ) { + expect(typeof row).toBe('object'); + + asserts.forEach((assert, i: number) => { + if (row[`col-${i}`]) { + assert(row[`col-${i}`]); + } + }); + } + + // check for two character country code + function expectCountry(val: string | number) { + expect(typeof val).toBe('string'); + expect(val).toHaveLength(2); + } + + // check for an OS term + function expectExtension(val: string | number) { + expect(val).toMatch(/^(js|png|html|css|jpg)$/); + } + + // check for an OS term + function expectOS(val: string | number) { + expect(val).toMatch(/^(win|mac|linux)$/); + } + + // check for something like an average bytes result + function expectAvgBytes(val: string | number) { + expect(typeof val).toBe('number'); + expect(val === 0 || val > 1000).toBeDefined(); + } + + test('for non-hierarchical vis', () => { + // the default for a non-hierarchical vis is to display + // only complete rows, and only put the metrics at the end. + + const tabbed = tabifyAggResponse(aggConfigs, esResp, { metricsAtAllLevels: false }); + + expectColumns(tabbed, [ext, src, os, avg]); + + tabbed.rows.forEach(row => { + expectRow(row, [expectExtension, expectCountry, expectOS, expectAvgBytes]); + }); + }); + + test('for hierarchical vis', () => { + const tabbed = tabifyAggResponse(aggConfigs, esResp, { metricsAtAllLevels: true }); + + expectColumns(tabbed, [ext, avg, src, avg, os, avg]); + + tabbed.rows.forEach(row => { + expectRow(row, [ + expectExtension, + expectAvgBytes, + expectCountry, + expectAvgBytes, + expectOS, + expectAvgBytes, + ]); + }); + }); + }); +}); diff --git a/src/legacy/core_plugins/data/public/search/tabify/tabify.ts b/src/legacy/core_plugins/data/public/search/tabify/tabify.ts new file mode 100644 index 0000000000000..078d3f7f72759 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/tabify/tabify.ts @@ -0,0 +1,173 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get } from 'lodash'; +import { TabbedAggResponseWriter } from './response_writer'; +import { TabifyBuckets } from './buckets'; +import { TabbedResponseWriterOptions, TabbedRangeFilterParams } from './types'; +import { AggResponseBucket } from '../types'; +import { IAggConfigs, AggGroupNames } from '../aggs'; + +/** + * Sets up the ResponseWriter and kicks off bucket collection. + */ +export function tabifyAggResponse( + aggConfigs: IAggConfigs, + esResponse: Record, + respOpts?: Partial +) { + /** + * read an aggregation from a bucket, which *might* be found at key (if + * the response came in object form), and will recurse down the aggregation + * tree and will pass the read values to the ResponseWriter. + */ + function collectBucket( + aggs: IAggConfigs, + write: TabbedAggResponseWriter, + bucket: AggResponseBucket, + key: string, + aggScale: number + ) { + const column = write.columns.shift(); + + if (column) { + const agg = column.aggConfig; + const aggInfo = agg.write(aggs); + aggScale *= aggInfo.metricScale || 1; + + switch (agg.type.type) { + case AggGroupNames.Buckets: + const aggBucket = get(bucket, agg.id); + const tabifyBuckets = new TabifyBuckets(aggBucket, agg.params, timeRange); + + if (tabifyBuckets.length) { + tabifyBuckets.forEach((subBucket, tabifyBucketKey) => { + // if the bucket doesn't have value don't add it to the row + // we don't want rows like: { column1: undefined, column2: 10 } + const bucketValue = agg.getKey(subBucket, tabifyBucketKey); + const hasBucketValue = typeof bucketValue !== 'undefined'; + + if (hasBucketValue) { + write.bucketBuffer.push({ id: column.id, value: bucketValue }); + } + + collectBucket( + aggs, + write, + subBucket, + agg.getKey(subBucket, tabifyBucketKey), + aggScale + ); + + if (hasBucketValue) { + write.bucketBuffer.pop(); + } + }); + } else if (respOpts?.partialRows) { + // we don't have any buckets, but we do have metrics at this + // level, then pass all the empty buckets and jump back in for + // the metrics. + write.columns.unshift(column); + passEmptyBuckets(aggs, write, bucket, key, aggScale); + write.columns.shift(); + } else { + // we don't have any buckets, and we don't have isHierarchical + // data, so no metrics, just try to write the row + write.row(); + } + break; + case AggGroupNames.Metrics: + let value = agg.getValue(bucket); + // since the aggregation could be a non integer (such as a max date) + // only do the scaling calculation if it is needed. + if (aggScale !== 1) { + value *= aggScale; + } + write.metricBuffer.push({ id: column.id, value }); + + if (!write.columns.length) { + // row complete + write.row(); + } else { + // process the next agg at this same level + collectBucket(aggs, write, bucket, key, aggScale); + } + + write.metricBuffer.pop(); + + break; + } + + write.columns.unshift(column); + } + } + + // write empty values for each bucket agg, then write + // the metrics from the initial bucket using collectBucket() + function passEmptyBuckets( + aggs: IAggConfigs, + write: TabbedAggResponseWriter, + bucket: AggResponseBucket, + key: string, + aggScale: number + ) { + const column = write.columns.shift(); + + if (column) { + const agg = column.aggConfig; + + switch (agg.type.type) { + case AggGroupNames.Metrics: + // pass control back to collectBucket() + write.columns.unshift(column); + collectBucket(aggs, write, bucket, key, aggScale); + return; + + case AggGroupNames.Buckets: + passEmptyBuckets(aggs, write, bucket, key, aggScale); + } + + write.columns.unshift(column); + } + } + + const write = new TabbedAggResponseWriter(aggConfigs, respOpts || {}); + const topLevelBucket: AggResponseBucket = { + ...esResponse.aggregations, + doc_count: esResponse.hits.total, + }; + + let timeRange: TabbedRangeFilterParams | undefined; + + // Extract the time range object if provided + if (respOpts && respOpts.timeRange) { + const [timeRangeKey] = Object.keys(respOpts.timeRange); + + if (timeRangeKey) { + timeRange = { + name: timeRangeKey, + ...respOpts.timeRange[timeRangeKey], + }; + } + } + + collectBucket(aggConfigs, write, topLevelBucket, '', 1); + + return write.response(); +} diff --git a/src/legacy/ui/public/agg_response/tabify/__tests__/tabify.js b/src/legacy/core_plugins/data/public/search/tabify/types.ts similarity index 69% rename from src/legacy/ui/public/agg_response/tabify/__tests__/tabify.js rename to src/legacy/core_plugins/data/public/search/tabify/types.ts index 38ed5408b603e..3a02a2b64f0c3 100644 --- a/src/legacy/ui/public/agg_response/tabify/__tests__/tabify.js +++ b/src/legacy/core_plugins/data/public/search/tabify/types.ts @@ -17,8 +17,16 @@ * under the License. */ -import './_get_columns'; -import './_buckets'; -import './_response_writer'; -import './_integration'; -describe('Tabify Agg Response', function() {}); +import { RangeFilterParams } from '../../../../../../plugins/data/public'; + +/** @internal **/ +export interface TabbedRangeFilterParams extends RangeFilterParams { + name: string; +} + +/** @internal **/ +export interface TabbedResponseWriterOptions { + metricsAtAllLevels: boolean; + partialRows: boolean; + timeRange?: { [key: string]: RangeFilterParams }; +} diff --git a/src/legacy/core_plugins/data/public/search/utils/types.ts b/src/legacy/core_plugins/data/public/search/utils/types.ts index 305f27a86b398..e0afe99aa81fa 100644 --- a/src/legacy/core_plugins/data/public/search/utils/types.ts +++ b/src/legacy/core_plugins/data/public/search/utils/types.ts @@ -31,3 +31,9 @@ export interface RequestInspectorStats { hits?: InspectorStat; requestTime?: InspectorStat; } + +export interface AggResponseBucket { + key_as_string: string; + key: number; + doc_count: number; +} diff --git a/src/legacy/core_plugins/input_control_vis/public/components/editor/controls_tab.test.tsx b/src/legacy/core_plugins/input_control_vis/public/components/editor/controls_tab.test.tsx index 3419d773bd09e..d7a62e07b26f3 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/editor/controls_tab.test.tsx +++ b/src/legacy/core_plugins/input_control_vis/public/components/editor/controls_tab.test.tsx @@ -23,6 +23,7 @@ import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { getDepsMock, getIndexPatternMock } from '../../test_utils'; import { ControlsTab, ControlsTabUiProps } from './controls_tab'; +import { Vis } from 'src/legacy/core_plugins/visualizations/public'; const indexPatternsMock = { get: getIndexPatternMock, @@ -32,7 +33,7 @@ let props: ControlsTabUiProps; beforeEach(() => { props = { deps: getDepsMock(), - vis: { + vis: ({ API: { indexPatterns: indexPatternsMock, }, @@ -46,7 +47,7 @@ beforeEach(() => { requiresSearch: false, hidden: false, }, - }, + } as unknown) as Vis, stateParams: { controls: [ { diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index b0bb17ce1ac7f..91b5c7f13dc95 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -58,8 +58,7 @@ export { stateMonitorFactory } from 'ui/state_management/state_monitor_factory'; export { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; // @ts-ignore export { timezoneProvider } from 'ui/vis/lib/timezone'; -// @ts-ignore -export { tabifyAggResponse } from 'ui/agg_response/tabify'; +export { tabifyAggResponse } from '../../../data/public'; export { unhashUrl } from '../../../../../plugins/kibana_utils/public'; export { migrateLegacyQuery, diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts index 92433799ba420..8b1bb0fda8c84 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts @@ -24,14 +24,11 @@ * directly where they are needed. */ -// @ts-ignore -export { AppState, AppStateProvider } from 'ui/state_management/app_state'; export { State } from 'ui/state_management/state'; // @ts-ignore export { GlobalStateProvider } from 'ui/state_management/global_state'; // @ts-ignore export { StateManagementConfigProvider } from 'ui/state_management/config_provider'; -export { stateMonitorFactory } from 'ui/state_management/state_monitor_factory'; export { PersistedState } from 'ui/persisted_state'; export { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts index bd7b478f827a6..6a8d9ce106f9d 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts @@ -22,8 +22,6 @@ import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; import { AppMountContext } from 'kibana/public'; import { - AppStateProvider, - AppState, configureAppAngularModule, createTopNavDirective, createTopNavHelper, @@ -116,12 +114,6 @@ function createLocalStateModule() { 'app/visualize/Promise', 'app/visualize/PersistedState', ]) - .factory('AppState', function(Private: IPrivate) { - return Private(AppStateProvider); - }) - .service('getAppState', function(Private: IPrivate) { - return Private(AppStateProvider).getAppState; - }) .service('globalState', function(Private: IPrivate) { return Private(GlobalStateProvider); }); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html index 6190b92c9be3e..4979d9dc89a0c 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html @@ -2,7 +2,7 @@
@@ -42,9 +42,9 @@ show-filter-bar="showFilterBar() && isVisible" show-date-picker="showQueryBarTimePicker()" show-auto-refresh-only="!showQueryBarTimePicker()" - query="state.query" + query="query" saved-query="savedQuery" - screen-title="state.vis.title" + screen-title="vis.title" on-query-submit="updateQueryAndFetch" index-patterns="[indexPattern]" filters="filters" @@ -97,7 +97,9 @@ ui-state="uiState" time-range="timeRange" filters="filters" - query="query"/> + query="query" + app-state="appState" + />

diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 409d4b41fbe69..415949f88e9d1 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -24,7 +24,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { migrateAppState } from './lib'; +import { makeStateful, useVisualizeAppState } from './lib'; import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from '../breadcrumbs'; @@ -45,7 +45,6 @@ import { absoluteToParsedUrl, KibanaParsedUrl, migrateLegacyQuery, - stateMonitorFactory, DashboardConstants, } from '../../legacy_imports'; @@ -68,15 +67,14 @@ function VisualizeAppController( $scope, $element, $route, - AppState, $window, $injector, $timeout, kbnUrl, redirectWhenMissing, Promise, - getAppState, - globalState + globalState, + config ) { const { indexPatterns, @@ -99,7 +97,6 @@ function VisualizeAppController( setActiveUrl, } = getServices(); - const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager); // Retrieve the resolved SavedVis instance. const savedVis = $route.current.locals.savedVis; const _applyVis = () => { @@ -113,9 +110,9 @@ function VisualizeAppController( $scope.vis = vis; - const $appStatus = (this.appStatus = { + const $appStatus = { dirty: !savedVis.id, - }); + }; vis.on('dirtyStateChange', ({ isDirty }) => { vis.dirty = isDirty; @@ -265,53 +262,61 @@ function VisualizeAppController( }, ]; - let stateMonitor; - if (savedVis.id) { chrome.docTitle.change(savedVis.title); } + const defaultQuery = { + query: '', + language: + localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), + }; + // Extract visualization state with filtered aggs. You can see these filtered aggs in the URL. // Consists of things like aggs, params, listeners, title, type, etc. const savedVisState = vis.getState(); const stateDefaults = { uiState: savedVis.uiStateJSON ? JSON.parse(savedVis.uiStateJSON) : {}, - linked: !!savedVis.savedSearchId, - query: searchSource.getOwnField('query') || { - query: '', - language: - localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), - }, + query: searchSource.getOwnField('query') || defaultQuery, filters: searchSource.getOwnField('filter') || [], vis: savedVisState, + linked: !!savedVis.savedSearchId, }; - // Instance of app_state.js. - const $state = (function initState() { - // This is used to sync visualization state with the url when `appState.save()` is called. - const appState = new AppState(stateDefaults); - - // Initializing appState does two things - first it translates the defaults into AppState, - // second it updates appState based on the url (the url trumps the defaults). This means if - // we update the state format at all and want to handle BWC, we must not only migrate the - // data stored with saved vis, but also any old state in the url. - migrateAppState(appState); - - // The savedVis is pulled from elasticsearch, but the appState is pulled from the url, with the - // defaults applied. If the url was from a previous session which included modifications to the - // appState then they won't be equal. - if (!angular.equals(appState.vis, savedVisState)) { - Promise.try(function() { - vis.setState(appState.vis); - }).catch( - redirectWhenMissing({ - 'index-pattern-field': '/visualize', - }) - ); - } + const useHash = config.get('state:storeInSessionStorage'); + const { stateContainer, stopStateSync } = useVisualizeAppState({ + useHash, + stateDefaults, + }); + + const filterStateManager = new FilterStateManager( + globalState, + () => { + // Temporary AppState replacement + return { + set filters(_filters) { + stateContainer.transitions.set('filters', _filters); + }, + get filters() { + return stateContainer.getState().filters; + }, + }; + }, + filterManager + ); - return appState; - })(); + // The savedVis is pulled from elasticsearch, but the appState is pulled from the url, with the + // defaults applied. If the url was from a previous session which included modifications to the + // appState then they won't be equal. + if (!_.isEqual(stateContainer.getState().vis, stateDefaults.vis)) { + try { + vis.setState(stateContainer.getState().vis); + } catch { + redirectWhenMissing({ + 'index-pattern-field': '/visualize', + }); + } + } $scope.filters = filterManager.getFilters(); @@ -330,8 +335,6 @@ function VisualizeAppController( ); function init() { - // export some objects - $scope.savedVis = savedVis; if (vis.indexPattern) { $scope.indexPattern = vis.indexPattern; } else { @@ -340,14 +343,28 @@ function VisualizeAppController( }); } + const initialState = stateContainer.getState(); + + $scope.appState = { + // mock implementation of the legacy appState.save() + // this could be even replaced by passing only "updateAppState" callback + save() { + stateContainer.transitions.updateVisState(vis.getState()); + }, + }; + + // Create a PersistedState instance for uiState. + const { persistedState, unsubscribePersisted, persistOnChange } = makeStateful( + 'uiState', + stateContainer + ); + $scope.uiState = persistedState; + $scope.savedVis = savedVis; + $scope.query = initialState.query; + $scope.linked = initialState.linked; $scope.searchSource = searchSource; - $scope.state = $state; $scope.refreshInterval = timefilter.getRefreshInterval(); - // Create a PersistedState instance. - $scope.uiState = $state.makeStateful('uiState'); - $scope.appStatus = $appStatus; - const addToDashMode = $route.current.params[DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM]; kbnUrl.removeParam(DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM); @@ -372,22 +389,23 @@ function VisualizeAppController( $scope.timeRange = timefilter.getTime(); $scope.opts = _.pick($scope, 'savedVis', 'isAddToDashMode'); - stateMonitor = stateMonitorFactory.create($state, stateDefaults); - stateMonitor.ignoreProps(['vis.listeners']).onChange(status => { - $appStatus.dirty = status.dirty || !savedVis.id; - }); + const unsubscribeStateUpdates = stateContainer.subscribe(state => { + const newQuery = migrateLegacyQuery(state.query); + if (!_.isEqual(state.query, newQuery)) { + stateContainer.transitions.set('query', newQuery); + } + persistOnChange(state); - $scope.$watch('state.query', (newQuery, oldQuery) => { - if (!_.isEqual(newQuery, oldQuery)) { - const query = migrateLegacyQuery(newQuery); - if (!_.isEqual(query, newQuery)) { - $state.query = query; - } - $scope.fetch(); + // if the browser history was changed manually we need to reflect changes in the editor + if (!_.isEqual(vis.getState(), state.vis)) { + vis.setState(state.vis); + vis.forceReload(); + vis.emit('updateEditor'); } - }); - $state.replace(); + $appStatus.dirty = true; + $scope.fetch(); + }); const updateTimeRange = () => { $scope.timeRange = timefilter.getTime(); @@ -419,10 +437,11 @@ function VisualizeAppController( // update the searchSource when query updates $scope.fetch = function() { - $state.save(); - $scope.query = $state.query; - savedVis.searchSource.setField('query', $state.query); - savedVis.searchSource.setField('filter', $state.filters); + const { query, filters, linked } = stateContainer.getState(); + $scope.query = query; + $scope.linked = linked; + savedVis.searchSource.setField('query', query); + savedVis.searchSource.setField('filter', filters); $scope.$broadcast('render'); }; @@ -446,10 +465,13 @@ function VisualizeAppController( $scope._handler.destroy(); } savedVis.destroy(); - stateMonitor.destroy(); filterStateManager.destroy(); subscriptions.unsubscribe(); $scope.vis.off('apply', _applyVis); + + unsubscribePersisted(); + unsubscribeStateUpdates(); + stopStateSync(); }); $timeout(() => { @@ -459,10 +481,10 @@ function VisualizeAppController( $scope.updateQueryAndFetch = function({ query, dateRange }) { const isUpdate = - (query && !_.isEqual(query, $state.query)) || + (query && !_.isEqual(query, stateContainer.getState().query)) || (dateRange && !_.isEqual(dateRange, $scope.timeRange)); - $state.query = query; + stateContainer.transitions.set('query', query); timefilter.setTime(dateRange); // If nothing has changed, trigger the fetch manually, otherwise it will happen as a result of the changes @@ -488,20 +510,13 @@ function VisualizeAppController( $scope.onClearSavedQuery = () => { delete $scope.savedQuery; - delete $state.savedQuery; - $state.query = { - query: '', - language: - localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), - }; + stateContainer.transitions.removeSavedQuery(defaultQuery); filterManager.setFilters(filterManager.getGlobalFilters()); - $state.save(); $scope.fetch(); }; const updateStateFromSavedQuery = savedQuery => { - $state.query = savedQuery.attributes.query; - $state.save(); + stateContainer.transitions.set('query', savedQuery.attributes.query); const savedQueryFilters = savedQuery.attributes.filters || []; const globalFilters = filterManager.getGlobalFilters(); @@ -520,44 +535,38 @@ function VisualizeAppController( $scope.fetch(); }; + // update the query if savedQuery is stored + if (stateContainer.getState().savedQuery) { + savedQueryService.getSavedQuery(stateContainer.getState().savedQuery).then(savedQuery => { + $scope.$evalAsync(() => { + $scope.savedQuery = savedQuery; + }); + }); + } + $scope.$watch('savedQuery', newSavedQuery => { if (!newSavedQuery) return; - $state.savedQuery = newSavedQuery.id; - $state.save(); + stateContainer.transitions.set('savedQuery', newSavedQuery.id); updateStateFromSavedQuery(newSavedQuery); }); - $scope.$watch('state.savedQuery', newSavedQueryId => { - if (!newSavedQueryId) { - $scope.savedQuery = undefined; - return; - } - if (!$scope.savedQuery || newSavedQueryId !== $scope.savedQuery.id) { - savedQueryService.getSavedQuery(newSavedQueryId).then(savedQuery => { - $scope.$evalAsync(() => { - $scope.savedQuery = savedQuery; - updateStateFromSavedQuery(savedQuery); - }); - }); - } - }); - /** * Called when the user clicks "Save" button. */ function doSave(saveOptions) { // vis.title was not bound and it's needed to reflect title into visState - $state.vis.title = savedVis.title; - $state.vis.type = savedVis.type || $state.vis.type; - savedVis.visState = $state.vis; + stateContainer.transitions.setVis({ + title: savedVis.title, + type: savedVis.type || stateContainer.getState().vis.type, + }); + savedVis.visState = stateContainer.getState().vis; savedVis.uiStateJSON = angular.toJson($scope.uiState.getChanges()); + $appStatus.dirty = false; return savedVis.save(saveOptions).then( function(id) { $scope.$evalAsync(() => { - stateMonitor.setInitialState($state.toJSON()); - if (id) { toastNotifications.addSuccess({ title: i18n.translate( @@ -601,8 +610,6 @@ function VisualizeAppController( chrome.setBreadcrumbs($injector.invoke(getEditBreadcrumbs)); savedVis.vis.title = savedVis.title; savedVis.vis.description = savedVis.description; - // it's needed to save the state to update url string - $state.save(); } else { kbnUrl.change(`${VisualizeConstants.EDIT_PATH}/{{id}}`, { id: savedVis.id }); } @@ -632,9 +639,8 @@ function VisualizeAppController( } $scope.unlink = function() { - if (!$state.linked) return; + if (!$scope.linked) return; - $state.linked = false; const searchSourceParent = searchSource.getParent(); const searchSourceGrandparent = searchSourceParent.getParent(); @@ -645,8 +651,10 @@ function VisualizeAppController( _.union(searchSource.getOwnField('filter'), searchSourceParent.getOwnField('filter')) ); - $state.query = searchSourceParent.getField('query'); - $state.filters = searchSourceParent.getField('filter'); + stateContainer.transitions.unlinkSavedSearch( + searchSourceParent.getField('query'), + searchSourceParent.getField('filter') + ); searchSource.setField('index', searchSourceParent.getField('index')); searchSource.setParent(searchSourceGrandparent); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/index.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/index.ts similarity index 87% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/index.js rename to src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/index.ts index 42284c9a03dcd..fa5b91b00edaf 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/index.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { migrateAppState } from './migrate_app_state'; +export { useVisualizeAppState } from './visualize_app_state'; +export { makeStateful } from './make_stateful'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/make_stateful.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/make_stateful.ts new file mode 100644 index 0000000000000..137d4de1fe9a8 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/make_stateful.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PersistedState } from '../../../legacy_imports'; +import { ReduxLikeStateContainer } from '../../../../../../../../plugins/kibana_utils/public'; +import { VisualizeAppState, VisualizeAppStateTransitions } from '../../types'; + +/** + * @returns Create a PersistedState instance, initialize state changes subscriber/unsubscriber + */ +export function makeStateful( + prop: keyof VisualizeAppState, + stateContainer: ReduxLikeStateContainer +) { + // set up the persistedState state + const persistedState = new PersistedState(); + + // update the appState when the stateful instance changes + const updateOnChange = function() { + stateContainer.transitions.set(prop, persistedState.getChanges()); + }; + + const handlerOnChange = (method: 'on' | 'off') => + persistedState[method]('change', updateOnChange); + + handlerOnChange('on'); + const unsubscribePersisted = () => handlerOnChange('off'); + + // update the stateful object when the app state changes + const persistOnChange = function(state: VisualizeAppState) { + if (state[prop]) { + persistedState.set(state[prop]); + } + }; + + const appState = stateContainer.getState(); + + // if the thing we're making stateful has an appState value, write to persisted state + if (appState[prop]) persistedState.setSilent(appState[prop]); + + return { persistedState, unsubscribePersisted, persistOnChange }; +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/migrate_app_state.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/migrate_app_state.ts similarity index 61% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/migrate_app_state.js rename to src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/migrate_app_state.ts index 049ce048239db..7e09aece52e09 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/migrate_app_state.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/migrate_app_state.ts @@ -18,6 +18,7 @@ */ import { get, omit } from 'lodash'; +import { VisualizeAppState } from '../../types'; /** * Creates a new instance of AppState based on the table vis state. @@ -25,37 +26,41 @@ import { get, omit } from 'lodash'; * Dashboards have a similar implementation; see * core_plugins/kibana/public/dashboard/lib/migrate_app_state * - * @param appState {AppState} AppState class to instantiate + * @param appState {VisualizeAppState} */ -export function migrateAppState(appState) { +export function migrateAppState(appState: VisualizeAppState) { // For BWC in pre 7.0 versions where table visualizations could have multiple aggs // with `schema === 'split'`. This ensures that bookmarked URLs with deprecated params // are rewritten to the correct state. See core_plugins/table_vis/migrations. if (appState.vis.type !== 'table') { - return; + return appState; } - const visAggs = get(appState, 'vis.aggs', []); - let splitCount = 0; - const migratedAggs = visAggs.map(agg => { - if (agg.schema !== 'split') { + const visAggs: any = get(appState, 'vis.aggs'); + + if (visAggs) { + let splitCount = 0; + const migratedAggs = visAggs.map((agg: any) => { + if (agg.schema !== 'split') { + return agg; + } + + splitCount++; + if (splitCount === 1) { + return agg; // leave the first split agg unchanged + } + agg.schema = 'bucket'; + // the `row` param is exclusively used by split aggs, so we remove it + agg.params = omit(agg.params, ['row']); return agg; - } + }); - splitCount++; - if (splitCount === 1) { - return agg; // leave the first split agg unchanged + if (splitCount <= 1) { + return appState; // do nothing; we only want to touch tables with multiple split aggs } - agg.schema = 'bucket'; - // the `row` param is exclusively used by split aggs, so we remove it - agg.params = omit(agg.params, ['row']); - return agg; - }); - - if (splitCount <= 1) { - return; // do nothing; we only want to touch tables with multiple split aggs + + appState.vis.aggs = migratedAggs; } - appState.vis.aggs = migratedAggs; - appState.save(); + return appState; } diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/visualize_app_state.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/visualize_app_state.ts new file mode 100644 index 0000000000000..d8de81193d857 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/visualize_app_state.ts @@ -0,0 +1,112 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHashHistory } from 'history'; +import { isFunction, omit } from 'lodash'; + +import { migrateAppState } from './migrate_app_state'; +import { + createKbnUrlStateStorage, + createStateContainer, + syncState, +} from '../../../../../../../../plugins/kibana_utils/public'; +import { PureVisState, VisualizeAppState, VisualizeAppStateTransitions } from '../../types'; + +const STATE_STORAGE_KEY = '_a'; + +interface Arguments { + useHash: boolean; + stateDefaults: VisualizeAppState; +} + +function toObject(state: PureVisState): PureVisState { + return omit(state, (value, key: string) => { + return key.charAt(0) === '$' || key.charAt(0) === '_' || isFunction(value); + }); +} + +export function useVisualizeAppState({ useHash, stateDefaults }: Arguments) { + const history = createHashHistory(); + const kbnUrlStateStorage = createKbnUrlStateStorage({ + useHash, + history, + }); + const urlState = kbnUrlStateStorage.get(STATE_STORAGE_KEY); + const initialState = migrateAppState({ + ...stateDefaults, + ...urlState, + }); + + /* + make sure url ('_a') matches initial state + Initializing appState does two things - first it translates the defaults into AppState, + second it updates appState based on the url (the url trumps the defaults). This means if + we update the state format at all and want to handle BWC, we must not only migrate the + data stored with saved vis, but also any old state in the url. + */ + kbnUrlStateStorage.set(STATE_STORAGE_KEY, initialState, { replace: true }); + + const stateContainer = createStateContainer( + initialState, + { + set: state => (prop, value) => ({ ...state, [prop]: value }), + setVis: state => vis => ({ + ...state, + vis: { + ...state.vis, + ...vis, + }, + }), + removeSavedQuery: state => defaultQuery => { + const { savedQuery, ...rest } = state; + + return { + ...rest, + query: defaultQuery, + }; + }, + unlinkSavedSearch: state => (query, filters) => ({ + ...state, + query, + filters, + linked: false, + }), + updateVisState: state => newVisState => ({ ...state, vis: toObject(newVisState) }), + } + ); + + const { start: startStateSync, stop: stopStateSync } = syncState({ + storageKey: STATE_STORAGE_KEY, + stateContainer: { + ...stateContainer, + set: state => { + if (state) { + // syncState utils requires to handle incoming "null" value + stateContainer.set(state); + } + }, + }, + stateStorage: kbnUrlStateStorage, + }); + + // start syncing the appState with the ('_a') url + startStateSync(); + + return { stateContainer, stopStateSync }; +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js index 502bd6e56fb1f..6acdb0abdd0b5 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js @@ -18,7 +18,7 @@ */ export function initVisualizationDirective(app, deps) { - app.directive('visualizationEmbedded', function($timeout, getAppState) { + app.directive('visualizationEmbedded', function($timeout) { return { restrict: 'E', scope: { @@ -27,6 +27,7 @@ export function initVisualizationDirective(app, deps) { timeRange: '=', filters: '=', query: '=', + appState: '=', }, link: function($scope, element) { $scope.renderFunction = async () => { @@ -37,7 +38,7 @@ export function initVisualizationDirective(app, deps) { timeRange: $scope.timeRange, filters: $scope.filters || [], query: $scope.query, - appState: getAppState(), + appState: $scope.appState, uiState: $scope.uiState, }); $scope._handler.render(element[0]); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js index 8032152f88173..c40a10115ae4e 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js @@ -18,7 +18,7 @@ */ export function initVisEditorDirective(app, deps) { - app.directive('visualizationEditor', function($timeout, getAppState) { + app.directive('visualizationEditor', function($timeout) { return { restrict: 'E', scope: { @@ -27,6 +27,7 @@ export function initVisEditorDirective(app, deps) { timeRange: '=', filters: '=', query: '=', + appState: '=', }, link: function($scope, element) { const Editor = $scope.savedObj.vis.type.editor; @@ -41,7 +42,7 @@ export function initVisEditorDirective(app, deps) { timeRange: $scope.timeRange, filters: $scope.filters, query: $scope.query, - appState: getAppState(), + appState: $scope.appState, }); }; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts index 524bc4b3196b7..139c247aa29cc 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts @@ -20,10 +20,37 @@ import { TimeRange, Query, Filter, DataPublicPluginStart } from 'src/plugins/data/public'; import { IEmbeddableStart } from 'src/plugins/embeddable/public'; import { LegacyCoreStart } from 'kibana/public'; -import { VisSavedObject, AppState, PersistedState } from '../legacy_imports'; +import { VisState, Vis } from 'src/legacy/core_plugins/visualizations/public'; +import { VisSavedObject, PersistedState } from '../legacy_imports'; + +export type PureVisState = ReturnType; + +export interface VisualizeAppState { + filters: Filter[]; + uiState: PersistedState; + vis: PureVisState; + query: Query; + savedQuery?: string; + linked: boolean; +} + +export interface VisualizeAppStateTransitions { + set: ( + state: VisualizeAppState + ) => ( + prop: T, + value: VisualizeAppState[T] + ) => VisualizeAppState; + setVis: (state: VisualizeAppState) => (vis: Partial) => VisualizeAppState; + removeSavedQuery: (state: VisualizeAppState) => (defaultQuery: Query) => VisualizeAppState; + unlinkSavedSearch: ( + state: VisualizeAppState + ) => (query: Query, filters: Filter[]) => VisualizeAppState; + updateVisState: (state: VisualizeAppState) => (vis: PureVisState) => VisualizeAppState; +} export interface EditorRenderProps { - appState: AppState; + appState: { save(): void }; core: LegacyCoreStart; data: DataPublicPluginStart; embeddable: IEmbeddableStart; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index e33e83fd19fec..8615bcdd1bfbd 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -26,7 +26,7 @@ import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { PersistedState, AggGroupNames } from '../../legacy_imports'; import { DefaultEditorNavBar, OptionTab } from './navbar'; import { DefaultEditorControls } from './controls'; -import { setStateParamValue, useEditorReducer, useEditorFormState } from './state'; +import { setStateParamValue, useEditorReducer, useEditorFormState, discardChanges } from './state'; import { DefaultEditorAggCommonProps } from '../agg_common_props'; interface DefaultEditorSideBarProps { @@ -104,15 +104,26 @@ function DefaultEditorSideBar({ ); useEffect(() => { - vis.on('dirtyStateChange', ({ isDirty: dirty }: { isDirty: boolean }) => { + const changeHandler = ({ isDirty: dirty }: { isDirty: boolean }) => { setDirty(dirty); if (!dirty) { resetValidity(); } - }); + }; + vis.on('dirtyStateChange', changeHandler); + + return () => vis.off('dirtyStateChange', changeHandler); }, [resetValidity, vis]); + // subscribe on external vis changes using browser history, for example press back button + useEffect(() => { + const resetHandler = () => dispatch(discardChanges(vis)); + vis.on('updateEditor', resetHandler); + + return () => vis.off('updateEditor', resetHandler); + }, [dispatch, vis]); + const dataTabProps = { dispatch, formIsTouched: formState.touched, diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js index 0dbff60613cb0..9fe7920588cd2 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js @@ -21,7 +21,11 @@ import $ from 'jquery'; import moment from 'moment'; import ngMock from 'ng_mock'; import expect from '@kbn/expect'; -import fixtures from 'fixtures/fake_hierarchical_data'; +import { + metricOnly, + threeTermBuckets, + oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative, +} from 'fixtures/fake_hierarchical_data'; import sinon from 'sinon'; import { tabifyAggResponse, npStart } from '../../legacy_imports'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; @@ -44,7 +48,7 @@ describe('Table Vis - AggTable Directive', function() { const init = () => { const vis1 = new visualizationsStart.Vis(indexPattern, 'table'); - tabifiedData.metricOnly = tabifyAggResponse(vis1.aggs, fixtures.metricOnly); + tabifiedData.metricOnly = tabifyAggResponse(vis1.aggs, metricOnly); const vis2 = new visualizationsStart.Vis(indexPattern, { type: 'table', @@ -61,7 +65,7 @@ describe('Table Vis - AggTable Directive', function() { vis2.aggs.aggs.forEach(function(agg, i) { agg.id = 'agg_' + (i + 1); }); - tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.aggs, fixtures.threeTermBuckets, { + tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.aggs, threeTermBuckets, { metricsAtAllLevels: true, }); @@ -94,7 +98,7 @@ describe('Table Vis - AggTable Directive', function() { tabifiedData.oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = tabifyAggResponse( vis3.aggs, - fixtures.oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative + oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative ); }; diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js index f6ae41b024b7d..79d4d7c40d355 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js @@ -20,7 +20,7 @@ import $ from 'jquery'; import ngMock from 'ng_mock'; import expect from '@kbn/expect'; -import fixtures from 'fixtures/fake_hierarchical_data'; +import { metricOnly, threeTermBuckets } from 'fixtures/fake_hierarchical_data'; import { tabifyAggResponse, npStart } from '../../legacy_imports'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import { getAngularModule } from '../../get_inner_angular'; @@ -36,7 +36,7 @@ describe('Table Vis - AggTableGroup Directive', function() { const init = () => { const vis1 = new visualizationsStart.Vis(indexPattern, 'table'); - tabifiedData.metricOnly = tabifyAggResponse(vis1.aggs, fixtures.metricOnly); + tabifiedData.metricOnly = tabifyAggResponse(vis1.aggs, metricOnly); const vis2 = new visualizationsStart.Vis(indexPattern, { type: 'pie', @@ -50,7 +50,7 @@ describe('Table Vis - AggTableGroup Directive', function() { vis2.aggs.aggs.forEach(function(agg, i) { agg.id = 'agg_' + (i + 1); }); - tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.aggs, fixtures.threeTermBuckets); + tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.aggs, threeTermBuckets); }; const initLocalAngular = () => { diff --git a/src/legacy/core_plugins/vis_type_table/public/legacy_imports.ts b/src/legacy/core_plugins/vis_type_table/public/legacy_imports.ts index cb44814897bcf..90929150de9c3 100644 --- a/src/legacy/core_plugins/vis_type_table/public/legacy_imports.ts +++ b/src/legacy/core_plugins/vis_type_table/public/legacy_imports.ts @@ -24,9 +24,7 @@ export { IAggConfig, AggGroupNames, Schemas } from 'ui/agg_types'; export { PaginateDirectiveProvider } from 'ui/directives/paginate'; // @ts-ignore export { PaginateControlsDirectiveProvider } from 'ui/directives/paginate'; -export { tabifyGetColumns } from 'ui/agg_response/tabify/_get_columns'; -// @ts-ignore -export { tabifyAggResponse } from 'ui/agg_response/tabify'; +export { tabifyAggResponse, tabifyGetColumns } from '../../data/public'; export { configureAppAngularModule, KbnAccessibleClickProvider, diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js index b2dd1813e6d20..0263f5b2c851c 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js @@ -82,7 +82,6 @@ export class VisEditor extends Component { // This check should be redundant, since this method should only be called when we're in editor // mode where there's also an appState passed into us. if (this.props.appState) { - this.props.appState.vis = this.props.vis.getState(); this.props.appState.save(); } }, VIS_STATE_DEBOUNCE_DELAY); diff --git a/src/legacy/core_plugins/vis_type_vislib/public/legacy_imports.ts b/src/legacy/core_plugins/vis_type_vislib/public/legacy_imports.ts index 9c79be98a320c..1c8e679f7d61f 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/legacy_imports.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/legacy_imports.ts @@ -19,10 +19,8 @@ export { AggType, AggGroupNames, IAggConfig, IAggType, Schemas } from 'ui/agg_types'; export { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; -// @ts-ignore -export { tabifyAggResponse } from 'ui/agg_response/tabify'; +export { tabifyAggResponse, tabifyGetColumns } from '../../data/public'; // @ts-ignore export { buildHierarchicalData } from 'ui/agg_response/hierarchical/build_hierarchical_data'; // @ts-ignore export { buildPointSeriesData } from 'ui/agg_response/point_series/point_series'; -export { tabifyGetColumns } from '../../../ui/public/agg_response/tabify/_get_columns'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/vislib/__tests__/visualizations/pie_chart.js b/src/legacy/core_plugins/vis_type_vislib/public/vislib/__tests__/visualizations/pie_chart.js index 534a523103774..9c9c5a84f046c 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/vislib/__tests__/visualizations/pie_chart.js +++ b/src/legacy/core_plugins/vis_type_vislib/public/vislib/__tests__/visualizations/pie_chart.js @@ -22,7 +22,7 @@ import _ from 'lodash'; import $ from 'jquery'; import expect from '@kbn/expect'; -import fixtures from 'fixtures/fake_hierarchical_data'; +import { threeTermBuckets } from 'fixtures/fake_hierarchical_data'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import { start as visualizationsStart } from '../../../../../visualizations/public/np_ready/public/legacy'; @@ -147,7 +147,7 @@ describe('No global chart settings', function() { }); beforeEach(async () => { - const table1 = tabifyAggResponse(stubVis1.aggs, fixtures.threeTermBuckets, { + const table1 = tabifyAggResponse(stubVis1.aggs, threeTermBuckets, { metricsAtAllLevels: true, }); data1 = await responseHandler(table1, rowAggDimensions); @@ -234,7 +234,7 @@ describe('Vislib PieChart Class Test Suite', function() { }); beforeEach(async () => { - const table = tabifyAggResponse(stubVis.aggs, fixtures.threeTermBuckets, { + const table = tabifyAggResponse(stubVis.aggs, threeTermBuckets, { metricsAtAllLevels: true, }); data = await responseHandler(table, dataDimensions); diff --git a/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable.ts index 9e388832283fa..e7ca5ea803701 100644 --- a/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -23,7 +23,6 @@ import { Subscription } from 'rxjs'; import * as Rx from 'rxjs'; import { buildPipeline } from 'ui/visualize/loader/pipeline_helpers'; import { SavedObject } from 'ui/saved_objects/types'; -import { AppState } from 'ui/state_management/app_state'; import { npStart } from 'ui/new_platform'; import { IExpressionLoaderParams } from 'src/plugins/expressions/public'; import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; @@ -68,7 +67,7 @@ export interface VisualizeEmbeddableConfiguration { indexPatterns?: IIndexPattern[]; editUrl: string; editable: boolean; - appState?: AppState; + appState?: { save(): void }; uiState?: PersistedState; } @@ -79,7 +78,7 @@ export interface VisualizeInput extends EmbeddableInput { vis?: { colors?: { [key: string]: string }; }; - appState?: AppState; + appState?: { save(): void }; uiState?: PersistedState; } @@ -95,7 +94,7 @@ type ExpressionLoader = InstanceType { private handler?: ExpressionLoader; private savedVisualization: VisSavedObject; - private appState: AppState | undefined; + private appState: { save(): void } | undefined; private uiState: PersistedState; private timeRange?: TimeRange; private query?: Query; @@ -389,7 +388,6 @@ export class VisualizeEmbeddable extends Embeddable { if (this.appState) { - this.appState.vis = this.savedVisualization.vis.getState(); this.appState.save(); } diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts index d1017de35474a..f73dc3e19d0ef 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts @@ -26,7 +26,7 @@ import { SchemaConfig, Schemas, } from './build_pipeline'; -import { Vis, VisState } from '..'; +import { Vis } from '..'; import { IAggConfig } from '../../../legacy_imports'; import { searchSourceMock } from '../../../legacy_mocks'; @@ -83,7 +83,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { }); describe('buildPipelineVisFunction', () => { - let visStateDef: VisState; + let visStateDef: ReturnType; let schemaConfig: SchemaConfig; let schemasDef: Schemas; let uiState: any; @@ -94,7 +94,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { // @ts-ignore type: 'type', params: {}, - }; + } as ReturnType; schemaConfig = { accessor: 0, @@ -349,7 +349,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { describe('buildPipeline', () => { it('calls toExpression on vis_type if it exists', async () => { - const vis: Vis = { + const vis = ({ getCurrentState: () => {}, getUiState: () => null, isHierarchical: () => false, @@ -360,7 +360,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { type: { toExpression: () => 'testing custom expressions', }, - }; + } as unknown) as Vis; const expression = await buildPipeline(vis, { searchSource: searchSourceMock }); expect(expression).toMatchSnapshot(); }); diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts index 04a296a888e87..025eef834ca86 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts @@ -28,7 +28,7 @@ import { isDateHistogramBucketAggConfig, createFormat, } from '../../../legacy_imports'; -import { Vis, VisParams, VisState } from '..'; +import { Vis, VisParams } from '..'; interface SchemaConfigParams { precision?: number; @@ -59,7 +59,7 @@ export interface Schemas { } type buildVisFunction = ( - visState: VisState, + visState: ReturnType, schemas: Schemas, uiState: any, meta?: { savedObjectId?: string } diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis.ts index 59a8013523ef6..19375e25a9fb7 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis.ts @@ -23,6 +23,14 @@ import { Status } from './legacy/update_status'; export interface Vis { type: VisType; + getCurrentState: ( + includeDisabled?: boolean + ) => { + title: string; + type: string; + params: VisParams; + aggs: Array<{ [key: string]: any }>; + }; // Since we haven't typed everything here yet, we basically "any" the rest // of that interface. This should be removed as soon as this type definition diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts index 45d65efb5dcdf..f9b7db5c02d93 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Vis, VisState } from './vis'; +import { Vis, VisState, VisParams } from './vis'; import { VisType } from './types'; import { IIndexPattern } from '../../../../../../plugins/data/common'; @@ -35,6 +35,14 @@ export declare class VisImpl implements Vis { constructor(indexPattern: IIndexPattern, visState?: InitVisStateType); type: VisType; + getCurrentState: ( + includeDisabled?: boolean + ) => { + title: string; + type: string; + params: VisParams; + aggs: Array<{ [key: string]: any }>; + }; // Since we haven't typed everything here yet, we basically "any" the rest // of that interface. This should be removed as soon as this type definition diff --git a/src/legacy/ui/public/agg_response/index.js b/src/legacy/ui/public/agg_response/index.js index 41d45d1a06ca4..139a124356de2 100644 --- a/src/legacy/ui/public/agg_response/index.js +++ b/src/legacy/ui/public/agg_response/index.js @@ -19,7 +19,7 @@ import { buildHierarchicalData } from './hierarchical/build_hierarchical_data'; import { buildPointSeriesData } from './point_series/point_series'; -import { tabifyAggResponse } from './tabify/tabify'; +import { tabifyAggResponse } from '../../../core_plugins/data/public'; export const aggResponseIndex = { hierarchical: buildHierarchicalData, diff --git a/src/legacy/ui/public/agg_response/tabify/__tests__/_get_columns.js b/src/legacy/ui/public/agg_response/tabify/__tests__/_get_columns.js deleted file mode 100644 index 3eb41c03050d0..0000000000000 --- a/src/legacy/ui/public/agg_response/tabify/__tests__/_get_columns.js +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import { tabifyGetColumns } from '../_get_columns'; -import { start as visualizationsStart } from '../../../../../core_plugins/visualizations/public/np_ready/public/legacy'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; -describe('get columns', function() { - let indexPattern; - - beforeEach(ngMock.module('kibana')); - beforeEach( - ngMock.inject(function(Private) { - indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - }) - ); - - it('should inject a count metric if no aggs exist', function() { - const vis = new visualizationsStart.Vis(indexPattern, { - type: 'pie', - }); - while (vis.aggs.length) vis.aggs.pop(); - const columns = tabifyGetColumns( - vis.getAggConfig().getResponseAggs(), - null, - vis.isHierarchical() - ); - - expect(columns).to.have.length(1); - expect(columns[0]).to.have.property('aggConfig'); - expect(columns[0].aggConfig.type).to.have.property('name', 'count'); - }); - - it('should inject a count metric if only buckets exist', function() { - const vis = new visualizationsStart.Vis(indexPattern, { - type: 'pie', - aggs: [ - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - ], - }); - - const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), !vis.isHierarchical()); - - expect(columns).to.have.length(2); - expect(columns[1]).to.have.property('aggConfig'); - expect(columns[1].aggConfig.type).to.have.property('name', 'count'); - }); - - it('should inject the metric after each bucket if the vis is hierarchical', function() { - const vis = new visualizationsStart.Vis(indexPattern, { - type: 'pie', - aggs: [ - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - ], - }); - - const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), !vis.isHierarchical()); - - expect(columns).to.have.length(8); - columns.forEach(function(column, i) { - expect(column).to.have.property('aggConfig'); - expect(column.aggConfig.type).to.have.property('name', i % 2 ? 'count' : 'date_histogram'); - }); - }); - - it('should inject the multiple metrics after each bucket if the vis is hierarchical', function() { - const vis = new visualizationsStart.Vis(indexPattern, { - type: 'pie', - aggs: [ - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { type: 'sum', schema: 'metric', params: { field: 'bytes' } }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - ], - }); - - const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), !vis.isHierarchical()); - - function checkColumns(column, i) { - expect(column).to.have.property('aggConfig'); - switch (i) { - case 0: - expect(column.aggConfig.type).to.have.property('name', 'date_histogram'); - break; - case 1: - expect(column.aggConfig.type).to.have.property('name', 'avg'); - break; - case 2: - expect(column.aggConfig.type).to.have.property('name', 'sum'); - break; - } - } - - expect(columns).to.have.length(12); - for (let i = 0; i < columns.length; i += 3) { - columns.slice(i, i + 3).forEach(checkColumns); - } - }); - - it('should put all metrics at the end of the columns if the vis is not hierarchical', function() { - const vis = new visualizationsStart.Vis(indexPattern, { - type: 'histogram', - aggs: [ - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - { type: 'sum', schema: 'metric', params: { field: 'bytes' } }, - { - type: 'date_histogram', - schema: 'segment', - params: { field: '@timestamp', interval: '10s' }, - }, - ], - }); - - const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), !vis.isHierarchical()); - expect(columns).to.have.length(6); - - // sum should be last - expect(columns.pop().aggConfig.type).to.have.property('name', 'sum'); - // avg should be before that - expect(columns.pop().aggConfig.type).to.have.property('name', 'avg'); - // the rest are date_histograms - while (columns.length) { - expect(columns.pop().aggConfig.type).to.have.property('name', 'date_histogram'); - } - }); -}); diff --git a/src/legacy/ui/public/agg_response/tabify/__tests__/_integration.js b/src/legacy/ui/public/agg_response/tabify/__tests__/_integration.js deleted file mode 100644 index f3f2e20149acf..0000000000000 --- a/src/legacy/ui/public/agg_response/tabify/__tests__/_integration.js +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import fixtures from 'fixtures/fake_hierarchical_data'; -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import { tabifyAggResponse } from '../tabify'; -import { start as visualizationsStart } from '../../../../../core_plugins/visualizations/public/np_ready/public/legacy'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -describe('tabifyAggResponse Integration', function() { - let indexPattern; - - beforeEach(ngMock.module('kibana')); - beforeEach( - ngMock.inject(function(Private) { - indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - }) - ); - - function normalizeIds(vis) { - vis.aggs.aggs.forEach(function(agg, i) { - agg.id = 'agg_' + (i + 1); - }); - } - - it('transforms a simple response properly', function() { - const vis = new visualizationsStart.Vis(indexPattern, { - type: 'histogram', - aggs: [], - }); - normalizeIds(vis); - - const resp = tabifyAggResponse(vis.getAggConfig(), fixtures.metricOnly, { - metricsAtAllLevels: vis.isHierarchical(), - }); - - expect(resp) - .to.have.property('rows') - .and.property('columns'); - expect(resp.rows).to.have.length(1); - expect(resp.columns).to.have.length(1); - - expect(resp.rows[0]).to.eql({ 'col-0-agg_1': 1000 }); - expect(resp.columns[0]).to.have.property('aggConfig', vis.aggs[0]); - }); - - describe('transforms a complex response', function() { - this.slow(1000); - - let vis; - let avg; - let ext; - let src; - let os; - let esResp; - - beforeEach(function() { - vis = new visualizationsStart.Vis(indexPattern, { - type: 'pie', - aggs: [ - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { type: 'terms', schema: 'split', params: { field: 'extension' } }, - { type: 'terms', schema: 'segment', params: { field: 'geo.src' } }, - { type: 'terms', schema: 'segment', params: { field: 'machine.os' } }, - ], - }); - normalizeIds(vis); - - avg = vis.aggs[0]; - ext = vis.aggs[1]; - src = vis.aggs[2]; - os = vis.aggs[3]; - - esResp = _.cloneDeep(fixtures.threeTermBuckets); - // remove the buckets for css in MX - esResp.aggregations.agg_2.buckets[1].agg_3.buckets[0].agg_4.buckets = []; - }); - - // check that the columns of a table are formed properly - function expectColumns(table, aggs) { - expect(table.columns) - .to.be.an('array') - .and.have.length(aggs.length); - aggs.forEach(function(agg, i) { - expect(table.columns[i]).to.have.property('aggConfig', agg); - }); - } - - // check that a row has expected values - function expectRow(row, asserts) { - expect(row).to.be.an('object'); - asserts.forEach(function(assert, i) { - if (row[`col-${i}`]) { - assert(row[`col-${i}`]); - } - }); - } - - // check for two character country code - function expectCountry(val) { - expect(val).to.be.a('string'); - expect(val).to.have.length(2); - } - - // check for an OS term - function expectExtension(val) { - expect(val).to.match(/^(js|png|html|css|jpg)$/); - } - - // check for an OS term - function expectOS(val) { - expect(val).to.match(/^(win|mac|linux)$/); - } - - // check for something like an average bytes result - function expectAvgBytes(val) { - expect(val).to.be.a('number'); - expect(val === 0 || val > 1000).to.be.ok(); - } - - it('for non-hierarchical vis', function() { - // the default for a non-hierarchical vis is to display - // only complete rows, and only put the metrics at the end. - - const tabbed = tabifyAggResponse(vis.getAggConfig(), esResp, { metricsAtAllLevels: false }); - - expectColumns(tabbed, [ext, src, os, avg]); - - tabbed.rows.forEach(function(row) { - expectRow(row, [expectExtension, expectCountry, expectOS, expectAvgBytes]); - }); - }); - - it('for hierarchical vis', function() { - // since we have partialRows we expect that one row will have some empty - // values, and since the vis is hierarchical and we are NOT using - // minimalColumns we should expect the partial row to be completely after - // the existing bucket and it's metric - - vis.isHierarchical = _.constant(true); - const tabbed = tabifyAggResponse(vis.getAggConfig(), esResp, { metricsAtAllLevels: true }); - - expectColumns(tabbed, [ext, avg, src, avg, os, avg]); - - tabbed.rows.forEach(function(row) { - expectRow(row, [ - expectExtension, - expectAvgBytes, - expectCountry, - expectAvgBytes, - expectOS, - expectAvgBytes, - ]); - }); - }); - }); -}); diff --git a/src/legacy/ui/public/agg_response/tabify/__tests__/_response_writer.js b/src/legacy/ui/public/agg_response/tabify/__tests__/_response_writer.js deleted file mode 100644 index b0c0f2f3d9100..0000000000000 --- a/src/legacy/ui/public/agg_response/tabify/__tests__/_response_writer.js +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import { TabbedAggResponseWriter } from '../_response_writer'; -import { start as visualizationsStart } from '../../../../../core_plugins/visualizations/public/np_ready/public/legacy'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -describe('TabbedAggResponseWriter class', function() { - let Private; - let indexPattern; - - beforeEach(ngMock.module('kibana')); - beforeEach( - ngMock.inject(function($injector) { - Private = $injector.get('Private'); - - indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - }) - ); - - const splitAggConfig = [ - { - type: 'terms', - params: { - field: 'geo.src', - }, - }, - ]; - - const twoSplitsAggConfig = [ - { - type: 'terms', - params: { - field: 'geo.src', - }, - }, - { - type: 'terms', - params: { - field: 'machine.os.raw', - }, - }, - ]; - - const createResponseWritter = (aggs = [], opts = {}) => { - const vis = new visualizationsStart.Vis(indexPattern, { type: 'histogram', aggs: aggs }); - return new TabbedAggResponseWriter(vis.getAggConfig(), opts); - }; - - describe('Constructor', function() { - let responseWriter; - beforeEach(() => { - responseWriter = createResponseWritter(twoSplitsAggConfig); - }); - - it('creates aggStack', () => { - expect(responseWriter.aggStack.length).to.eql(3); - }); - - it('generates columns', () => { - expect(responseWriter.columns.length).to.eql(3); - }); - - it('correctly generates columns with metricsAtAllLevels set to true', () => { - const minimalColumnsResponseWriter = createResponseWritter(twoSplitsAggConfig, { - metricsAtAllLevels: true, - }); - expect(minimalColumnsResponseWriter.columns.length).to.eql(4); - }); - - describe('sets timeRange', function() { - it("to the first nested object's range", function() { - const vis = new visualizationsStart.Vis(indexPattern, { type: 'histogram', aggs: [] }); - const range = { - gte: 0, - lte: 100, - }; - - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - timeRange: { - '@timestamp': range, - }, - }); - - expect(writer.timeRange.gte).to.be(range.gte); - expect(writer.timeRange.lte).to.be(range.lte); - expect(writer.timeRange.name).to.be('@timestamp'); - }); - - it('to undefined if no nested object', function() { - const vis = new visualizationsStart.Vis(indexPattern, { type: 'histogram', aggs: [] }); - - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - timeRange: {}, - }); - expect(writer).to.have.property('timeRange', undefined); - }); - }); - }); - - describe('row()', function() { - let responseWriter; - - beforeEach(() => { - responseWriter = createResponseWritter(splitAggConfig, { partialRows: true }); - }); - - it('adds the row to the array', () => { - responseWriter.rowBuffer['col-0'] = 'US'; - responseWriter.rowBuffer['col-1'] = 5; - responseWriter.row(); - expect(responseWriter.rows.length).to.eql(1); - expect(responseWriter.rows[0]).to.eql({ 'col-0': 'US', 'col-1': 5 }); - }); - - it('correctly handles bucketBuffer', () => { - responseWriter.bucketBuffer.push({ id: 'col-0', value: 'US' }); - responseWriter.rowBuffer['col-1'] = 5; - responseWriter.row(); - expect(responseWriter.rows.length).to.eql(1); - expect(responseWriter.rows[0]).to.eql({ 'col-0': 'US', 'col-1': 5 }); - }); - - it("doesn't add an empty row", () => { - responseWriter.row(); - expect(responseWriter.rows.length).to.eql(0); - }); - }); - - describe('response()', () => { - let responseWriter; - - beforeEach(() => { - responseWriter = createResponseWritter(splitAggConfig); - }); - - it('produces correct response', () => { - responseWriter.rowBuffer['col-0-1'] = 'US'; - responseWriter.rowBuffer['col-1-2'] = 5; - responseWriter.row(); - const response = responseWriter.response(); - expect(response).to.have.property('rows'); - expect(response.rows).to.eql([{ 'col-0-1': 'US', 'col-1-2': 5 }]); - expect(response).to.have.property('columns'); - expect(response.columns.length).to.equal(2); - expect(response.columns[0]).to.have.property('id', 'col-0-1'); - expect(response.columns[0]).to.have.property('name', 'geo.src: Descending'); - expect(response.columns[0]).to.have.property('aggConfig'); - expect(response.columns[1]).to.have.property('id', 'col-1-2'); - expect(response.columns[1]).to.have.property('name', 'Count'); - expect(response.columns[1]).to.have.property('aggConfig'); - }); - - it('produces correct response for no data', () => { - const response = responseWriter.response(); - expect(response).to.have.property('rows'); - expect(response.rows.length).to.be(0); - expect(response).to.have.property('columns'); - expect(response.columns.length).to.equal(2); - expect(response.columns[0]).to.have.property('id', 'col-0-1'); - expect(response.columns[0]).to.have.property('name', 'geo.src: Descending'); - expect(response.columns[0]).to.have.property('aggConfig'); - expect(response.columns[1]).to.have.property('id', 'col-1-2'); - expect(response.columns[1]).to.have.property('name', 'Count'); - expect(response.columns[1]).to.have.property('aggConfig'); - }); - }); -}); diff --git a/src/legacy/ui/public/agg_response/tabify/_buckets.js b/src/legacy/ui/public/agg_response/tabify/_buckets.js deleted file mode 100644 index 7180a056ab0ca..0000000000000 --- a/src/legacy/ui/public/agg_response/tabify/_buckets.js +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import moment from 'moment'; - -function TabifyBuckets(aggResp, aggParams, timeRange) { - if (_.has(aggResp, 'buckets')) { - this.buckets = aggResp.buckets; - } else if (aggResp) { - // Some Bucket Aggs only return a single bucket (like filter). - // In those instances, the aggResp is the content of the single bucket. - this.buckets = [aggResp]; - } else { - this.buckets = []; - } - - this.objectMode = _.isPlainObject(this.buckets); - if (this.objectMode) { - this._keys = _.keys(this.buckets); - this.length = this._keys.length; - } else { - this.length = this.buckets.length; - } - - if (this.length && aggParams) { - this._orderBucketsAccordingToParams(aggParams); - if (aggParams.drop_partials) { - this._dropPartials(aggParams, timeRange); - } - } -} - -TabifyBuckets.prototype.forEach = function(fn) { - const buckets = this.buckets; - - if (this.objectMode) { - this._keys.forEach(function(key) { - fn(buckets[key], key); - }); - } else { - buckets.forEach(function(bucket) { - fn(bucket, bucket.key); - }); - } -}; - -TabifyBuckets.prototype._isRangeEqual = function(range1, range2) { - return ( - _.get(range1, 'from', null) === _.get(range2, 'from', null) && - _.get(range1, 'to', null) === _.get(range2, 'to', null) - ); -}; - -TabifyBuckets.prototype._orderBucketsAccordingToParams = function(params) { - if (params.filters && this.objectMode) { - this._keys = params.filters.map(filter => { - const query = _.get(filter, 'input.query.query_string.query', filter.input.query); - const queryString = typeof query === 'string' ? query : JSON.stringify(query); - return filter.label || queryString || '*'; - }); - } else if (params.ranges && this.objectMode) { - this._keys = params.ranges.map(range => { - return _.findKey(this.buckets, el => this._isRangeEqual(el, range)); - }); - } else if (params.ranges && params.field.type !== 'date') { - let ranges = params.ranges; - if (params.ipRangeType) { - ranges = params.ipRangeType === 'mask' ? ranges.mask : ranges.fromTo; - } - this.buckets = ranges.map(range => { - if (range.mask) { - return this.buckets.find(el => el.key === range.mask); - } - return this.buckets.find(el => this._isRangeEqual(el, range)); - }); - } -}; - -// dropPartials should only be called if the aggParam setting is enabled, -// and the agg field is the same as the Time Range. -TabifyBuckets.prototype._dropPartials = function(params, timeRange) { - if ( - !timeRange || - this.buckets.length <= 1 || - this.objectMode || - params.field.name !== timeRange.name - ) { - return; - } - - const interval = this.buckets[1].key - this.buckets[0].key; - - this.buckets = this.buckets.filter(bucket => { - if (moment(bucket.key).isBefore(timeRange.gte)) { - return false; - } - if (moment(bucket.key + interval).isAfter(timeRange.lte)) { - return false; - } - return true; - }); - - this.length = this.buckets.length; -}; - -export { TabifyBuckets }; diff --git a/src/legacy/ui/public/agg_response/tabify/_response_writer.js b/src/legacy/ui/public/agg_response/tabify/_response_writer.js deleted file mode 100644 index 85586c7ca7fda..0000000000000 --- a/src/legacy/ui/public/agg_response/tabify/_response_writer.js +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { toArray } from 'lodash'; -import { tabifyGetColumns } from './_get_columns'; - -/** - * Writer class that collects information about an aggregation response and - * produces a table, or a series of tables. - * - * @param {AggConfigs} aggs - the agg configs object to which the aggregation response correlates - * @param {boolean} metricsAtAllLevels - setting to true will produce metrics for every bucket - * @param {boolean} partialRows - setting to true will not remove rows with missing values - * @param {Object} timeRange - time range object, if provided - */ -function TabbedAggResponseWriter( - aggs, - { metricsAtAllLevels = false, partialRows = false, timeRange } = {} -) { - // Private - this._removePartialRows = !partialRows; - - // Public - this.rowBuffer = {}; - this.bucketBuffer = []; - this.metricBuffer = []; - this.aggs = aggs; - this.partialRows = partialRows; - this.columns = tabifyGetColumns(aggs.getResponseAggs(), !metricsAtAllLevels); - this.aggStack = [...this.columns]; - this.rows = []; - // Extract the time range object if provided - if (timeRange) { - const timeRangeKey = Object.keys(timeRange)[0]; - this.timeRange = timeRange[timeRangeKey]; - if (this.timeRange) { - this.timeRange.name = timeRangeKey; - } - } -} - -TabbedAggResponseWriter.prototype.isPartialRow = function(row) { - return !this.columns.map(column => row.hasOwnProperty(column.id)).every(c => c === true); -}; - -/** - * Create a new row by reading the row buffer and bucketBuffer - */ -TabbedAggResponseWriter.prototype.row = function() { - this.bucketBuffer.forEach(bucket => { - this.rowBuffer[bucket.id] = bucket.value; - }); - - this.metricBuffer.forEach(metric => { - this.rowBuffer[metric.id] = metric.value; - }); - - if ( - !toArray(this.rowBuffer).length || - (this._removePartialRows && this.isPartialRow(this.rowBuffer)) - ) { - return; - } - - this.rows.push(this.rowBuffer); - this.rowBuffer = {}; -}; - -/** - * Get the actual response - * - * @return {object} - the final table - */ -TabbedAggResponseWriter.prototype.response = function() { - return { - columns: this.columns, - rows: this.rows, - }; -}; - -export { TabbedAggResponseWriter }; diff --git a/src/legacy/ui/public/agg_response/tabify/tabify.js b/src/legacy/ui/public/agg_response/tabify/tabify.js deleted file mode 100644 index 8316055cb15cc..0000000000000 --- a/src/legacy/ui/public/agg_response/tabify/tabify.js +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import { TabbedAggResponseWriter } from './_response_writer'; -import { TabifyBuckets } from './_buckets'; - -/** - * Sets up the ResponseWriter and kicks off bucket collection. - * - * @param {AggConfigs} aggs - the agg configs object to which the aggregation response correlates - * @param {Object} esResponse - response that came back from Elasticsearch - * @param {Object} respOpts - options object for the ResponseWriter with params set by Courier - * @param {boolean} respOpts.metricsAtAllLevels - setting to true will produce metrics for every bucket - * @param {boolean} respOpts.partialRows - setting to true will not remove rows with missing values - * @param {Object} respOpts.timeRange - time range object, if provided - */ -export function tabifyAggResponse(aggs, esResponse, respOpts = {}) { - const write = new TabbedAggResponseWriter(aggs, respOpts); - - const topLevelBucket = _.assign({}, esResponse.aggregations, { - doc_count: esResponse.hits.total, - }); - - collectBucket(write, topLevelBucket, '', 1); - - return write.response(); -} - -/** - * read an aggregation from a bucket, which *might* be found at key (if - * the response came in object form), and will recurse down the aggregation - * tree and will pass the read values to the ResponseWriter. - * - * @param {object} bucket - a bucket from the aggResponse - * @param {undefined|string} key - the key where the bucket was found - * @returns {undefined} - */ -function collectBucket(write, bucket, key, aggScale) { - const column = write.aggStack.shift(); - const agg = column.aggConfig; - const aggInfo = agg.write(write.aggs); - aggScale *= aggInfo.metricScale || 1; - - switch (agg.type.type) { - case 'buckets': - const buckets = new TabifyBuckets(bucket[agg.id], agg.params, write.timeRange); - if (buckets.length) { - buckets.forEach(function(subBucket, key) { - // if the bucket doesn't have value don't add it to the row - // we don't want rows like: { column1: undefined, column2: 10 } - const bucketValue = agg.getKey(subBucket, key); - const hasBucketValue = typeof bucketValue !== 'undefined'; - if (hasBucketValue) { - write.bucketBuffer.push({ id: column.id, value: bucketValue }); - } - collectBucket(write, subBucket, agg.getKey(subBucket, key), aggScale); - if (hasBucketValue) { - write.bucketBuffer.pop(); - } - }); - } else if (write.partialRows) { - // we don't have any buckets, but we do have metrics at this - // level, then pass all the empty buckets and jump back in for - // the metrics. - write.aggStack.unshift(column); - passEmptyBuckets(write, bucket, key, aggScale); - write.aggStack.shift(); - } else { - // we don't have any buckets, and we don't have isHierarchical - // data, so no metrics, just try to write the row - write.row(); - } - break; - case 'metrics': - let value = agg.getValue(bucket); - // since the aggregation could be a non integer (such as a max date) - // only do the scaling calculation if it is needed. - if (aggScale !== 1) { - value *= aggScale; - } - write.metricBuffer.push({ id: column.id, value: value }); - - if (!write.aggStack.length) { - // row complete - write.row(); - } else { - // process the next agg at this same level - collectBucket(write, bucket, key, aggScale); - } - - write.metricBuffer.pop(); - - break; - } - - write.aggStack.unshift(column); -} - -// write empty values for each bucket agg, then write -// the metrics from the initial bucket using collectBucket() -function passEmptyBuckets(write, bucket, key, aggScale) { - const column = write.aggStack.shift(); - const agg = column.aggConfig; - - switch (agg.type.type) { - case 'metrics': - // pass control back to collectBucket() - write.aggStack.unshift(column); - collectBucket(write, bucket, key, aggScale); - return; - - case 'buckets': - passEmptyBuckets(write, bucket, key, aggScale); - } - - write.aggStack.unshift(column); -} diff --git a/test/functional/apps/visualize/_heatmap_chart.js b/test/functional/apps/visualize/_heatmap_chart.js index 2cea861d0f64d..cbf7ab8df6831 100644 --- a/test/functional/apps/visualize/_heatmap_chart.js +++ b/test/functional/apps/visualize/_heatmap_chart.js @@ -96,6 +96,8 @@ export default function({ getService, getPageObjects }) { await PageObjects.visEditor.clickOptionsTab(); await PageObjects.visEditor.changeHeatmapColorNumbers(6); await PageObjects.visEditor.clickGo(); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const legends = await PageObjects.visChart.getLegendEntries(); const expectedLegends = [ '0 - 267', @@ -121,9 +123,9 @@ export default function({ getService, getPageObjects }) { log.debug('customize 2 last ranges'); await PageObjects.visEditor.setCustomRangeByIndex(6, '650', '720'); await PageObjects.visEditor.setCustomRangeByIndex(7, '800', '905'); + await PageObjects.visEditor.clickGo(); await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - await PageObjects.visEditor.clickGo(); const legends = await PageObjects.visChart.getLegendEntries(); const expectedLegends = [ '0 - 100', diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index fe67594ad8ac2..a9751003e8425 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -5,7 +5,6 @@ source test/scripts/jenkins_test_setup.sh if [[ -z "$CODE_COVERAGE" ]] ; then "$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:unit --dev; else - echo "NODE_ENV=$NODE_ENV" echo " -> Running jest tests with coverage" node scripts/jest --ci --verbose --coverage echo "" diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js index cb8b43a6c312b..0912e5a9f1283 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -11,7 +11,7 @@ import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import { HeatmapLayer } from '../../heatmap_layer'; import { VectorLayer } from '../../vector_layer'; import { AggConfigs, Schemas } from 'ui/agg_types'; -import { tabifyAggResponse } from 'ui/agg_response/tabify'; +import { tabifyAggResponse } from '../../../../../../../../src/legacy/core_plugins/data/public'; import { convertToGeoJson } from './convert_to_geojson'; import { VectorStyle } from '../../styles/vector/vector_style'; import { diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx index fd05b80e41235..b66cc77e30aad 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx @@ -16,7 +16,7 @@ import { RecursivePartial, } from '@elastic/charts'; import { getOr, get, isNull, isNumber } from 'lodash/fp'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { ChartPlaceHolder } from './chart_place_holder'; import { useTimeZone } from '../../lib/kibana'; diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx index 1355926d343df..da0f3d1d0047f 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { Chart, BarSeries, Axis, Position, ScaleType, Settings } from '@elastic/charts'; import { getOr, get, isNumber } from 'lodash/fp'; import deepmerge from 'deepmerge'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { useTimeZone } from '../../lib/kibana'; import { ChartPlaceHolder } from './chart_place_holder'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx index 8c4228b597dbb..d3cdf9886e469 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { mockIndexPattern, TestProviders } from '../../mock'; import { wait } from '../../lib/helpers'; @@ -29,7 +29,7 @@ mockUseFetchIndexPatterns.mockImplementation(() => [ ]); const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; -jest.mock('use-resize-observer'); +jest.mock('use-resize-observer/polyfilled'); mockUseResizeObserver.mockImplementation(() => ({})); const from = 1566943856794; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index 8e2c5bdd12d68..2a4d08ea214bc 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -9,7 +9,7 @@ import deepEqual from 'fast-deep-equal'; import { getOr, isEmpty, isEqual, union } from 'lodash/fp'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { BrowserFields } from '../../containers/source'; import { TimelineQuery } from '../../containers/timeline'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx index 2bedd1cb89b41..6f614c1e32f65 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { wait } from '../../lib/helpers'; import { mockIndexPattern, TestProviders } from '../../mock'; @@ -28,7 +28,7 @@ mockUseFetchIndexPatterns.mockImplementation(() => [ ]); const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; -jest.mock('use-resize-observer'); +jest.mock('use-resize-observer/polyfilled'); mockUseResizeObserver.mockImplementation(() => ({})); const from = 1566943856794; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx index 78899b7c5d628..4c5238d213e43 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { timelineQuery } from '../../containers/timeline/index.gql_query'; import { mockBrowserFields } from '../../containers/source/mock'; @@ -31,7 +31,7 @@ const testFlyoutHeight = 980; jest.mock('../../lib/kibana'); const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; -jest.mock('use-resize-observer'); +jest.mock('use-resize-observer/polyfilled'); mockUseResizeObserver.mockImplementation(() => ({})); describe('Timeline', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx index e6aa6cc18f018..c9ff0296a40e2 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { BrowserFields } from '../../containers/source'; import { TimelineQuery } from '../../containers/timeline'; diff --git a/x-pack/legacy/plugins/siem/public/pages/home/index.tsx b/x-pack/legacy/plugins/siem/public/pages/home/index.tsx index 4608079167ed5..7c5fd56bf1e91 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import styled from 'styled-components'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { DragDropContextWrapper } from '../../components/drag_and_drop/drag_drop_context_wrapper'; import { Flyout, flyoutHeaderHeight } from '../../components/flyout'; diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/details/details_tabs.test.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/details/details_tabs.test.tsx index bdc0f79e96591..81c1b317d4596 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/details/details_tabs.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/details/details_tabs.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; import { MemoryRouter } from 'react-router-dom'; -import useResizeObserver from 'use-resize-observer'; +import useResizeObserver from 'use-resize-observer/polyfilled'; import { mockIndexPattern } from '../../../mock/index_pattern'; import { TestProviders } from '../../../mock/test_providers'; @@ -37,7 +37,7 @@ jest.mock('../../../components/query_bar', () => ({ })); const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; -jest.mock('use-resize-observer'); +jest.mock('use-resize-observer/polyfilled'); mockUseResizeObserver.mockImplementation(() => ({})); describe('body', () => { diff --git a/x-pack/plugins/watcher/server/index.ts b/x-pack/plugins/watcher/server/index.ts index 51eb7bfa543fe..356be781fb194 100644 --- a/x-pack/plugins/watcher/server/index.ts +++ b/x-pack/plugins/watcher/server/index.ts @@ -6,4 +6,6 @@ import { PluginInitializerContext } from 'kibana/server'; import { WatcherServerPlugin } from './plugin'; +export { WatcherContext } from './plugin'; + export const plugin = (ctx: PluginInitializerContext) => new WatcherServerPlugin(ctx); diff --git a/x-pack/plugins/watcher/server/lib/call_with_request_factory.ts b/x-pack/plugins/watcher/server/lib/call_with_request_factory.ts deleted file mode 100644 index 4884c75436c24..0000000000000 --- a/x-pack/plugins/watcher/server/lib/call_with_request_factory.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ElasticsearchServiceSetup } from 'kibana/server'; -import { once } from 'lodash'; -import { elasticsearchJsPlugin } from './elasticsearch_js_plugin'; - -const callWithRequest = once((elasticsearchService: ElasticsearchServiceSetup) => { - const config = { plugins: [elasticsearchJsPlugin] }; - return elasticsearchService.createClient('watcher', config); -}); - -export const callWithRequestFactory = ( - elasticsearchService: ElasticsearchServiceSetup, - request: any -) => { - return (...args: any[]) => { - return ( - callWithRequest(elasticsearchService) - .asScoped(request) - // @ts-ignore - .callAsCurrentUser(...args) - ); - }; -}; diff --git a/x-pack/plugins/watcher/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.ts b/x-pack/plugins/watcher/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.ts index de01bd5965504..8e8ca369dd02b 100644 --- a/x-pack/plugins/watcher/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.ts +++ b/x-pack/plugins/watcher/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.ts @@ -4,24 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ +import { IScopedClusterClient } from 'kibana/server'; import { get } from 'lodash'; import { ES_SCROLL_SETTINGS } from '../../../common/constants'; -export function fetchAllFromScroll(response: any, callWithRequest: any, hits: any[] = []) { - const newHits = get(response, 'hits.hits', []); - const scrollId = get(response, '_scroll_id'); +export function fetchAllFromScroll( + searchResuls: any, + dataClient: IScopedClusterClient, + hits: any[] = [] +): Promise { + const newHits = get(searchResuls, 'hits.hits', []); + const scrollId = get(searchResuls, '_scroll_id'); if (newHits.length > 0) { hits.push(...newHits); - return callWithRequest('scroll', { - body: { - scroll: ES_SCROLL_SETTINGS.KEEPALIVE, - scroll_id: scrollId, - }, - }).then((innerResponse: any) => { - return fetchAllFromScroll(innerResponse, callWithRequest, hits); - }); + return dataClient + .callAsCurrentUser('scroll', { + body: { + scroll: ES_SCROLL_SETTINGS.KEEPALIVE, + scroll_id: scrollId, + }, + }) + .then((innerResponse: any) => { + return fetchAllFromScroll(innerResponse, dataClient, hits); + }); } return Promise.resolve(hits); diff --git a/x-pack/plugins/watcher/server/lib/license_pre_routing_factory/license_pre_routing_factory.ts b/x-pack/plugins/watcher/server/lib/license_pre_routing_factory/license_pre_routing_factory.ts index d010a23952725..1b2476fc78b45 100644 --- a/x-pack/plugins/watcher/server/lib/license_pre_routing_factory/license_pre_routing_factory.ts +++ b/x-pack/plugins/watcher/server/lib/license_pre_routing_factory/license_pre_routing_factory.ts @@ -12,13 +12,13 @@ import { } from 'kibana/server'; import { RouteDependencies } from '../../types'; -export const licensePreRoutingFactory = ( +export const licensePreRoutingFactory = ( { getLicenseStatus }: RouteDependencies, - handler: RequestHandler + handler: RequestHandler ) => { return function licenseCheck( ctx: RequestHandlerContext, - request: KibanaRequest, + request: KibanaRequest, response: KibanaResponseFactory ) { const licenseStatus = getLicenseStatus(); diff --git a/x-pack/plugins/watcher/server/plugin.ts b/x-pack/plugins/watcher/server/plugin.ts index 1f7b3823609ec..51d85c2001bd2 100644 --- a/x-pack/plugins/watcher/server/plugin.ts +++ b/x-pack/plugins/watcher/server/plugin.ts @@ -3,7 +3,20 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; + +declare module 'kibana/server' { + interface RequestHandlerContext { + watcher?: WatcherContext; + } +} + +import { + CoreSetup, + IScopedClusterClient, + Logger, + Plugin, + PluginInitializerContext, +} from 'kibana/server'; import { PLUGIN } from '../common/constants'; import { Dependencies, LicenseStatus, RouteDependencies } from './types'; import { LICENSE_CHECK_STATE } from '../../licensing/server'; @@ -15,6 +28,11 @@ import { registerWatchesRoutes } from './routes/api/watches'; import { registerWatchRoutes } from './routes/api/watch'; import { registerListFieldsRoute } from './routes/api/register_list_fields_route'; import { registerLoadHistoryRoute } from './routes/api/register_load_history_route'; +import { elasticsearchJsPlugin } from './lib/elasticsearch_js_plugin'; + +export interface WatcherContext { + client: IScopedClusterClient; +} export class WatcherServerPlugin implements Plugin { log: Logger; @@ -31,15 +49,20 @@ export class WatcherServerPlugin implements Plugin { { http, elasticsearch: elasticsearchService }: CoreSetup, { licensing }: Dependencies ) { - const elasticsearch = await elasticsearchService.adminClient; const router = http.createRouter(); const routeDependencies: RouteDependencies = { - elasticsearch, - elasticsearchService, router, getLicenseStatus: () => this.licenseStatus, }; + const config = { plugins: [elasticsearchJsPlugin] }; + const watcherESClient = elasticsearchService.createClient('watcher', config); + http.registerRouteHandlerContext('watcher', (ctx, request) => { + return { + client: watcherESClient.asScoped(request), + }; + }); + registerListFieldsRoute(routeDependencies); registerLoadHistoryRoute(routeDependencies); registerIndicesRoutes(routeDependencies); diff --git a/x-pack/plugins/watcher/server/routes/api/indices/register_get_route.ts b/x-pack/plugins/watcher/server/routes/api/indices/register_get_route.ts index 30607b82e3295..df6f62135baeb 100644 --- a/x-pack/plugins/watcher/server/routes/api/indices/register_get_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/indices/register_get_route.ts @@ -5,13 +5,14 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { reduce, size } from 'lodash'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { isEsError } from '../../../lib/is_es_error'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; +const bodySchema = schema.object({ pattern: schema.string() }, { allowUnknowns: true }); + function getIndexNamesFromAliasesResponse(json: Record) { return reduce( json, @@ -26,67 +27,66 @@ function getIndexNamesFromAliasesResponse(json: Record) { ); } -function getIndices(callWithRequest: any, pattern: string, limit = 10) { - return callWithRequest('indices.getAlias', { - index: pattern, - ignore: [404], - }).then((aliasResult: any) => { - if (aliasResult.status !== 404) { - const indicesFromAliasResponse = getIndexNamesFromAliasesResponse(aliasResult); - return indicesFromAliasResponse.slice(0, limit); - } - - const params = { +function getIndices(dataClient: IScopedClusterClient, pattern: string, limit = 10) { + return dataClient + .callAsCurrentUser('indices.getAlias', { index: pattern, ignore: [404], - body: { - size: 0, // no hits - aggs: { - indices: { - terms: { - field: '_index', - size: limit, + }) + .then((aliasResult: any) => { + if (aliasResult.status !== 404) { + const indicesFromAliasResponse = getIndexNamesFromAliasesResponse(aliasResult); + return indicesFromAliasResponse.slice(0, limit); + } + + const params = { + index: pattern, + ignore: [404], + body: { + size: 0, // no hits + aggs: { + indices: { + terms: { + field: '_index', + size: limit, + }, }, }, }, - }, - }; + }; - return callWithRequest('search', params).then((response: any) => { - if (response.status === 404 || !response.aggregations) { - return []; - } - return response.aggregations.indices.buckets.map((bucket: any) => bucket.key); + return dataClient.callAsCurrentUser('search', params).then((response: any) => { + if (response.status === 404 || !response.aggregations) { + return []; + } + return response.aggregations.indices.buckets.map((bucket: any) => bucket.key); + }); }); - }); } export function registerGetRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - const { pattern } = request.body; - - try { - const indices = await getIndices(callWithRequest, pattern); - return response.ok({ body: { indices } }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ statusCode: e.statusCode, body: e }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.post( { path: '/api/watcher/indices', validate: { - body: schema.object({}, { allowUnknowns: true }), + body: bodySchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const { pattern } = request.body; + + try { + const indices = await getIndices(ctx.watcher!.client, pattern); + return response.ok({ body: { indices } }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ statusCode: e.statusCode, body: e }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/license/register_refresh_route.ts b/x-pack/plugins/watcher/server/routes/api/license/register_refresh_route.ts index a61fd16e8be4a..bd537cd6d21ab 100644 --- a/x-pack/plugins/watcher/server/routes/api/license/register_refresh_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/license/register_refresh_route.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandler } from 'kibana/server'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; /* @@ -13,16 +12,15 @@ it needs to make a round-trip to the kibana server. This refresh endpoint is pro for when the client needs to check the license, but doesn't need to pull data from the server for any reason, i.e., when adding a new watch. */ -export function registerRefreshRoute(deps: RouteDependencies) { - const handler: RequestHandler = (ctx, request, response) => { - return response.ok({ body: { success: true } }); - }; +export function registerRefreshRoute(deps: RouteDependencies) { deps.router.get( { path: '/api/watcher/license/refresh', validate: false, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, (ctx, request, response) => { + return response.ok({ body: { success: true } }); + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/register_list_fields_route.ts b/x-pack/plugins/watcher/server/routes/api/register_list_fields_route.ts index 7c47379b87589..d72e5ad2f817d 100644 --- a/x-pack/plugins/watcher/server/routes/api/register_list_fields_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/register_list_fields_route.ts @@ -5,15 +5,18 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { IScopedClusterClient } from 'kibana/server'; import { isEsError } from '../../lib/is_es_error'; // @ts-ignore import { Fields } from '../../models/fields/index'; import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; import { RouteDependencies } from '../../types'; -function fetchFields(callWithRequest: any, indexes: string[]) { +const bodySchema = schema.object({ + indexes: schema.arrayOf(schema.string()), +}); + +function fetchFields(dataClient: IScopedClusterClient, indexes: string[]) { const params = { index: indexes, fields: ['*'], @@ -22,44 +25,39 @@ function fetchFields(callWithRequest: any, indexes: string[]) { ignore: 404, }; - return callWithRequest('fieldCaps', params); + return dataClient.callAsCurrentUser('fieldCaps', params); } export function registerListFieldsRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - const { indexes } = request.body; - - try { - const fieldsResponse = await fetchFields(callWithRequest, indexes); - const json = fieldsResponse.status === 404 ? { fields: [] } : fieldsResponse; - const fields = Fields.fromUpstreamJson(json); - return response.ok({ body: fields.downstreamJson }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ - statusCode: e.statusCode, - body: { - message: e.message, - }, - }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.post( { path: '/api/watcher/fields', validate: { - body: schema.object({ - indexes: schema.arrayOf(schema.string()), - }), + body: bodySchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const { indexes } = request.body; + + try { + const fieldsResponse = await fetchFields(ctx.watcher!.client, indexes); + const json = fieldsResponse.status === 404 ? { fields: [] } : fieldsResponse; + const fields = Fields.fromUpstreamJson(json); + return response.ok({ body: fields.downstreamJson }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: { + message: e.message, + }, + }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/register_load_history_route.ts b/x-pack/plugins/watcher/server/routes/api/register_load_history_route.ts index 1be8477df79bc..8c9068123ce8d 100644 --- a/x-pack/plugins/watcher/server/routes/api/register_load_history_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/register_load_history_route.ts @@ -6,8 +6,7 @@ import { schema } from '@kbn/config-schema'; import { get } from 'lodash'; -import { RequestHandler } from 'kibana/server'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { IScopedClusterClient } from 'kibana/server'; import { isEsError } from '../../lib/is_es_error'; import { INDEX_NAMES } from '../../../common/constants'; import { RouteDependencies } from '../../types'; @@ -15,8 +14,12 @@ import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory' // @ts-ignore import { WatchHistoryItem } from '../../models/watch_history_item/index'; -function fetchHistoryItem(callWithRequest: any, watchHistoryItemId: string) { - return callWithRequest('search', { +const paramsSchema = schema.object({ + id: schema.string(), +}); + +function fetchHistoryItem(dataClient: IScopedClusterClient, watchHistoryItemId: string) { + return dataClient.callAsCurrentUser('search', { index: INDEX_NAMES.WATCHER_HISTORY, body: { query: { @@ -29,49 +32,44 @@ function fetchHistoryItem(callWithRequest: any, watchHistoryItemId: string) { } export function registerLoadHistoryRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - const id = request.params.id; - - try { - const responseFromES = await fetchHistoryItem(callWithRequest, id); - const hit = get(responseFromES, 'hits.hits[0]'); - if (!hit) { - return response.notFound({ body: `Watch History Item with id = ${id} not found` }); - } - const watchHistoryItemJson = get(hit, '_source'); - const watchId = get(hit, '_source.watch_id'); - const json = { - id, - watchId, - watchHistoryItemJson, - includeDetails: true, - }; - - const watchHistoryItem = WatchHistoryItem.fromUpstreamJson(json); - return response.ok({ - body: { watchHistoryItem: watchHistoryItem.downstreamJson }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ statusCode: e.statusCode, body: e }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.get( { path: '/api/watcher/history/{id}', validate: { - params: schema.object({ - id: schema.string(), - }), + params: paramsSchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const id = request.params.id; + + try { + const responseFromES = await fetchHistoryItem(ctx.watcher!.client, id); + const hit = get(responseFromES, 'hits.hits[0]'); + if (!hit) { + return response.notFound({ body: `Watch History Item with id = ${id} not found` }); + } + const watchHistoryItemJson = get(hit, '_source'); + const watchId = get(hit, '_source.watch_id'); + const json = { + id, + watchId, + watchHistoryItemJson, + includeDetails: true, + }; + + const watchHistoryItem = WatchHistoryItem.fromUpstreamJson(json); + return response.ok({ + body: { watchHistoryItem: watchHistoryItem.downstreamJson }, + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ statusCode: e.statusCode, body: e }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/settings/register_load_route.ts b/x-pack/plugins/watcher/server/routes/api/settings/register_load_route.ts index 6c70c2d0d07b6..fe9dd32735692 100644 --- a/x-pack/plugins/watcher/server/routes/api/settings/register_load_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/settings/register_load_route.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IClusterClient, RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { isEsError } from '../../../lib/is_es_error'; // @ts-ignore import { Settings } from '../../../models/settings/index'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; -function fetchClusterSettings(client: IClusterClient) { +function fetchClusterSettings(client: IScopedClusterClient) { return client.callAsInternalUser('cluster.getSettings', { includeDefaults: true, filterPath: '**.xpack.notification', @@ -19,25 +19,24 @@ function fetchClusterSettings(client: IClusterClient) { } export function registerLoadRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - try { - const settings = await fetchClusterSettings(deps.elasticsearch); - return response.ok({ body: Settings.fromUpstreamJson(settings).downstreamJson }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ statusCode: e.statusCode, body: e }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; deps.router.get( { path: '/api/watcher/settings', validate: false, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + try { + const settings = await fetchClusterSettings(ctx.watcher!.client); + return response.ok({ body: Settings.fromUpstreamJson(settings).downstreamJson }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ statusCode: e.statusCode, body: e }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/action/register_acknowledge_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/action/register_acknowledge_route.ts index 08eec7456e3a5..9e024a63b82c5 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/action/register_acknowledge_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/action/register_acknowledge_route.ts @@ -6,60 +6,58 @@ import { schema } from '@kbn/config-schema'; import { get } from 'lodash'; -import { RequestHandler } from 'kibana/server'; -import { callWithRequestFactory } from '../../../../lib/call_with_request_factory'; +import { IScopedClusterClient } from 'kibana/server'; import { isEsError } from '../../../../lib/is_es_error'; // @ts-ignore import { WatchStatus } from '../../../../models/watch_status/index'; import { RouteDependencies } from '../../../../types'; import { licensePreRoutingFactory } from '../../../../lib/license_pre_routing_factory'; -function acknowledgeAction(callWithRequest: any, watchId: string, actionId: string) { - return callWithRequest('watcher.ackWatch', { +const paramsSchema = schema.object({ + watchId: schema.string(), + actionId: schema.string(), +}); + +function acknowledgeAction(dataClient: IScopedClusterClient, watchId: string, actionId: string) { + return dataClient.callAsCurrentUser('watcher.ackWatch', { id: watchId, action: actionId, }); } export function registerAcknowledgeRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - const { watchId, actionId } = request.params; - - try { - const hit = await acknowledgeAction(callWithRequest, watchId, actionId); - const watchStatusJson = get(hit, 'status'); - const json = { - id: watchId, - watchStatusJson, - }; - - const watchStatus = WatchStatus.fromUpstreamJson(json); - return response.ok({ - body: { watchStatus: watchStatus.downstreamJson }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - const body = e.statusCode === 404 ? `Watch with id = ${watchId} not found` : e; - return response.customError({ statusCode: e.statusCode, body }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.put( { path: '/api/watcher/watch/{watchId}/action/{actionId}/acknowledge', validate: { - params: schema.object({ - watchId: schema.string(), - actionId: schema.string(), - }), + params: paramsSchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const { watchId, actionId } = request.params; + + try { + const hit = await acknowledgeAction(ctx.watcher!.client, watchId, actionId); + const watchStatusJson = get(hit, 'status'); + const json = { + id: watchId, + watchStatusJson, + }; + + const watchStatus = WatchStatus.fromUpstreamJson(json); + return response.ok({ + body: { watchStatus: watchStatus.downstreamJson }, + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + const body = e.statusCode === 404 ? `Watch with id = ${watchId} not found` : e; + return response.customError({ statusCode: e.statusCode, body }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_activate_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_activate_route.ts index fdc20854ed8c2..1afeeb4e80efb 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_activate_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_activate_route.ts @@ -5,62 +5,59 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { get } from 'lodash'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { isEsError } from '../../../lib/is_es_error'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; // @ts-ignore import { WatchStatus } from '../../../models/watch_status/index'; -function activateWatch(callWithRequest: any, watchId: string) { - return callWithRequest('watcher.activateWatch', { +function activateWatch(dataClient: IScopedClusterClient, watchId: string) { + return dataClient.callAsCurrentUser('watcher.activateWatch', { id: watchId, }); } -export function registerActivateRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - - const { watchId } = request.params; - - try { - const hit = await activateWatch(callWithRequest, watchId); - const watchStatusJson = get(hit, 'status'); - const json = { - id: watchId, - watchStatusJson, - }; - - const watchStatus = WatchStatus.fromUpstreamJson(json); - return response.ok({ - body: { - watchStatus: watchStatus.downstreamJson, - }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - const body = e.statusCode === 404 ? `Watch with id = ${watchId} not found` : e; - return response.customError({ statusCode: e.statusCode, body }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; +const paramsSchema = schema.object({ + watchId: schema.string(), +}); +export function registerActivateRoute(deps: RouteDependencies) { deps.router.put( { path: '/api/watcher/watch/{watchId}/activate', validate: { - params: schema.object({ - watchId: schema.string(), - }), + params: paramsSchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const { watchId } = request.params; + + try { + const hit = await activateWatch(ctx.watcher!.client, watchId); + const watchStatusJson = get(hit, 'status'); + const json = { + id: watchId, + watchStatusJson, + }; + + const watchStatus = WatchStatus.fromUpstreamJson(json); + return response.ok({ + body: { + watchStatus: watchStatus.downstreamJson, + }, + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + const body = e.statusCode === 404 ? `Watch with id = ${watchId} not found` : e; + return response.customError({ statusCode: e.statusCode, body }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_deactivate_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_deactivate_route.ts index 08d99f42df054..3171d8ee2e1e5 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_deactivate_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_deactivate_route.ts @@ -3,63 +3,61 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { get } from 'lodash'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { isEsError } from '../../../lib/is_es_error'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; // @ts-ignore import { WatchStatus } from '../../../models/watch_status/index'; -function deactivateWatch(callWithRequest: any, watchId: string) { - return callWithRequest('watcher.deactivateWatch', { +const paramsSchema = schema.object({ + watchId: schema.string(), +}); + +function deactivateWatch(dataClient: IScopedClusterClient, watchId: string) { + return dataClient.callAsCurrentUser('watcher.deactivateWatch', { id: watchId, }); } export function registerDeactivateRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - - const { watchId } = request.params; - - try { - const hit = await deactivateWatch(callWithRequest, watchId); - const watchStatusJson = get(hit, 'status'); - const json = { - id: watchId, - watchStatusJson, - }; - - const watchStatus = WatchStatus.fromUpstreamJson(json); - return response.ok({ - body: { - watchStatus: watchStatus.downstreamJson, - }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - const body = e.statusCode === 404 ? `Watch with id = ${watchId} not found` : e; - return response.customError({ statusCode: e.statusCode, body }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.put( { path: '/api/watcher/watch/{watchId}/deactivate', validate: { - params: schema.object({ - watchId: schema.string(), - }), + params: paramsSchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const { watchId } = request.params; + + try { + const hit = await deactivateWatch(ctx.watcher!.client, watchId); + const watchStatusJson = get(hit, 'status'); + const json = { + id: watchId, + watchStatusJson, + }; + + const watchStatus = WatchStatus.fromUpstreamJson(json); + return response.ok({ + body: { + watchStatus: watchStatus.downstreamJson, + }, + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + const body = e.statusCode === 404 ? `Watch with id = ${watchId} not found` : e; + return response.customError({ statusCode: e.statusCode, body }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_delete_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_delete_route.ts index 6e95cf959bc9c..bfdf328550bbe 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_delete_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_delete_route.ts @@ -5,49 +5,46 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { IScopedClusterClient } from 'kibana/server'; import { isEsError } from '../../../lib/is_es_error'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; -function deleteWatch(callWithRequest: any, watchId: string) { - return callWithRequest('watcher.deleteWatch', { +const paramsSchema = schema.object({ + watchId: schema.string(), +}); + +function deleteWatch(dataClient: IScopedClusterClient, watchId: string) { + return dataClient.callAsCurrentUser('watcher.deleteWatch', { id: watchId, }); } export function registerDeleteRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - - const { watchId } = request.params; - - try { - return response.ok({ - body: await deleteWatch(callWithRequest, watchId), - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - const body = e.statusCode === 404 ? `Watch with id = ${watchId} not found` : e; - return response.customError({ statusCode: e.statusCode, body }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.delete( { path: '/api/watcher/watch/{watchId}', validate: { - params: schema.object({ - watchId: schema.string(), - }), + params: paramsSchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const { watchId } = request.params; + + try { + return response.ok({ + body: await deleteWatch(ctx.watcher!.client, watchId), + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + const body = e.statusCode === 404 ? `Watch with id = ${watchId} not found` : e; + return response.customError({ statusCode: e.statusCode, body }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_execute_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_execute_route.ts index fef6d07317da5..7aaa77c05a5f0 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_execute_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_execute_route.ts @@ -5,9 +5,8 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { get } from 'lodash'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { isEsError } from '../../../lib/is_es_error'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; @@ -19,60 +18,63 @@ import { Watch } from '../../../models/watch/index'; // @ts-ignore import { WatchHistoryItem } from '../../../models/watch_history_item/index'; -function executeWatch(callWithRequest: any, executeDetails: any, watchJson: any) { +const bodySchema = schema.object({ + executeDetails: schema.object({}, { allowUnknowns: true }), + watch: schema.object({}, { allowUnknowns: true }), +}); + +function executeWatch(dataClient: IScopedClusterClient, executeDetails: any, watchJson: any) { const body = executeDetails; body.watch = watchJson; - return callWithRequest('watcher.executeWatch', { + return dataClient.callAsCurrentUser('watcher.executeWatch', { body, }); } export function registerExecuteRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - const executeDetails = ExecuteDetails.fromDownstreamJson(request.body.executeDetails); - const watch = Watch.fromDownstreamJson(request.body.watch); - - try { - const hit = await executeWatch(callWithRequest, executeDetails.upstreamJson, watch.watchJson); - const id = get(hit, '_id'); - const watchHistoryItemJson = get(hit, 'watch_record'); - const watchId = get(hit, 'watch_record.watch_id'); - const json = { - id, - watchId, - watchHistoryItemJson, - includeDetails: true, - }; - - const watchHistoryItem = WatchHistoryItem.fromUpstreamJson(json); - return response.ok({ - body: { - watchHistoryItem: watchHistoryItem.downstreamJson, - }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ statusCode: e.statusCode, body: e }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.put( { path: '/api/watcher/watch/execute', validate: { - body: schema.object({ - executeDetails: schema.object({}, { allowUnknowns: true }), - watch: schema.object({}, { allowUnknowns: true }), - }), + body: bodySchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const executeDetails = ExecuteDetails.fromDownstreamJson(request.body.executeDetails); + const watch = Watch.fromDownstreamJson(request.body.watch); + + try { + const hit = await executeWatch( + ctx.watcher!.client, + executeDetails.upstreamJson, + watch.watchJson + ); + const id = get(hit, '_id'); + const watchHistoryItemJson = get(hit, 'watch_record'); + const watchId = get(hit, 'watch_record.watch_id'); + const json = { + id, + watchId, + watchHistoryItemJson, + includeDetails: true, + }; + + const watchHistoryItem = WatchHistoryItem.fromUpstreamJson(json); + return response.ok({ + body: { + watchHistoryItem: watchHistoryItem.downstreamJson, + }, + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ statusCode: e.statusCode, body: e }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_history_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_history_route.ts index 7f0f1ac8d66a3..b64c28e114b72 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_history_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_history_route.ts @@ -5,9 +5,8 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { get } from 'lodash'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { fetchAllFromScroll } from '../../../lib/fetch_all_from_scroll'; import { INDEX_NAMES, ES_SCROLL_SETTINGS } from '../../../../common/constants'; import { isEsError } from '../../../lib/is_es_error'; @@ -16,7 +15,15 @@ import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_facto // @ts-ignore import { WatchHistoryItem } from '../../../models/watch_history_item/index'; -function fetchHistoryItems(callWithRequest: any, watchId: any, startTime: any) { +const paramsSchema = schema.object({ + watchId: schema.string(), +}); + +const querySchema = schema.object({ + startTime: schema.string(), +}); + +function fetchHistoryItems(dataClient: IScopedClusterClient, watchId: any, startTime: any) { const params: any = { index: INDEX_NAMES.WATCHER_HISTORY, scroll: ES_SCROLL_SETTINGS.KEEPALIVE, @@ -37,61 +44,57 @@ function fetchHistoryItems(callWithRequest: any, watchId: any, startTime: any) { params.body.query.bool.must.push(timeRangeQuery); } - return callWithRequest('search', params).then((response: any) => - fetchAllFromScroll(response, callWithRequest) - ); + return dataClient + .callAsCurrentUser('search', params) + .then((response: any) => fetchAllFromScroll(response, dataClient)); } export function registerHistoryRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - const { watchId } = request.params; - const { startTime } = request.query; - - try { - const hits = await fetchHistoryItems(callWithRequest, watchId, startTime); - const watchHistoryItems = hits.map((hit: any) => { - const id = get(hit, '_id'); - const watchHistoryItemJson = get(hit, '_source'); - - const opts = { includeDetails: false }; - return WatchHistoryItem.fromUpstreamJson( - { - id, - watchId, - watchHistoryItemJson, - }, - opts - ); - }); - - return response.ok({ - body: { - watchHistoryItems: watchHistoryItems.map( - (watchHistoryItem: any) => watchHistoryItem.downstreamJson - ), - }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ statusCode: e.statusCode, body: e }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.get( { path: '/api/watcher/watch/{watchId}/history', validate: { - params: schema.object({ - watchId: schema.string(), - }), + params: paramsSchema, + query: querySchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const { watchId } = request.params; + const { startTime } = request.query; + + try { + const hits = await fetchHistoryItems(ctx.watcher!.client, watchId, startTime); + const watchHistoryItems = hits.map((hit: any) => { + const id = get(hit, '_id'); + const watchHistoryItemJson = get(hit, '_source'); + + const opts = { includeDetails: false }; + return WatchHistoryItem.fromUpstreamJson( + { + id, + watchId, + watchHistoryItemJson, + }, + opts + ); + }); + + return response.ok({ + body: { + watchHistoryItems: watchHistoryItems.map( + (watchHistoryItem: any) => watchHistoryItem.downstreamJson + ), + }, + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ statusCode: e.statusCode, body: e }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_load_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_load_route.ts index 91d71cd737121..6363054921333 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_load_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_load_route.ts @@ -5,65 +5,63 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { get } from 'lodash'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { isEsError } from '../../../lib/is_es_error'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; // @ts-ignore import { Watch } from '../../../models/watch/index'; import { RouteDependencies } from '../../../types'; -function fetchWatch(callWithRequest: any, watchId: string) { - return callWithRequest('watcher.getWatch', { +const paramsSchema = schema.object({ + id: schema.string(), +}); + +function fetchWatch(dataClient: IScopedClusterClient, watchId: string) { + return dataClient.callAsCurrentUser('watcher.getWatch', { id: watchId, }); } export function registerLoadRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - - const id = request.params.id; - - try { - const hit = await fetchWatch(callWithRequest, id); - const watchJson = get(hit, 'watch'); - const watchStatusJson = get(hit, 'status'); - const json = { - id, - watchJson, - watchStatusJson, - }; - - const watch = Watch.fromUpstreamJson(json, { - throwExceptions: { - Action: false, - }, - }); - return response.ok({ - body: { watch: watch.downstreamJson }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - const body = e.statusCode === 404 ? `Watch with id = ${id} not found` : e; - return response.customError({ statusCode: e.statusCode, body }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; deps.router.get( { path: '/api/watcher/watch/{id}', validate: { - params: schema.object({ - id: schema.string(), - }), + params: paramsSchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const id = request.params.id; + + try { + const hit = await fetchWatch(ctx.watcher!.client, id); + const watchJson = get(hit, 'watch'); + const watchStatusJson = get(hit, 'status'); + const json = { + id, + watchJson, + watchStatusJson, + }; + + const watch = Watch.fromUpstreamJson(json, { + throwExceptions: { + Action: false, + }, + }); + return response.ok({ + body: { watch: watch.downstreamJson }, + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + const body = e.statusCode === 404 ? `Watch with id = ${id} not found` : e; + return response.customError({ statusCode: e.statusCode, body }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_save_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_save_route.ts index 7986424e6229a..572790f12a5f8 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_save_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_save_route.ts @@ -5,98 +5,104 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { WATCH_TYPES } from '../../../../common/constants'; import { serializeJsonWatch, serializeThresholdWatch } from '../../../../common/lib/serialization'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { isEsError } from '../../../lib/is_es_error'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; -function fetchWatch(callWithRequest: any, watchId: string) { - return callWithRequest('watcher.getWatch', { +const paramsSchema = schema.object({ + id: schema.string(), +}); + +const bodySchema = schema.object( + { + type: schema.string(), + isNew: schema.boolean(), + }, + { allowUnknowns: true } +); + +function fetchWatch(dataClient: IScopedClusterClient, watchId: string) { + return dataClient.callAsCurrentUser('watcher.getWatch', { id: watchId, }); } -function saveWatch(callWithRequest: any, id: string, body: any) { - return callWithRequest('watcher.putWatch', { +function saveWatch(dataClient: IScopedClusterClient, id: string, body: any) { + return dataClient.callAsCurrentUser('watcher.putWatch', { id, body, }); } export function registerSaveRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - const { id } = request.params; - const { type, isNew, ...watchConfig } = request.body; + deps.router.put( + { + path: '/api/watcher/watch/{id}', + validate: { + params: paramsSchema, + body: bodySchema, + }, + }, + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const { id } = request.params; + const { type, isNew, ...watchConfig } = request.body; - // For new watches, verify watch with the same ID doesn't already exist - if (isNew) { - try { - const existingWatch = await fetchWatch(callWithRequest, id); - if (existingWatch.found) { - return response.conflict({ - body: { - message: i18n.translate('xpack.watcher.saveRoute.duplicateWatchIdErrorMessage', { - defaultMessage: "There is already a watch with ID '{watchId}'.", - values: { - watchId: id, - }, - }), - }, - }); - } - } catch (e) { - const es404 = isEsError(e) && e.statusCode === 404; - if (!es404) { - return response.internalError({ body: e }); + // For new watches, verify watch with the same ID doesn't already exist + if (isNew) { + try { + const existingWatch = await fetchWatch(ctx.watcher!.client, id); + if (existingWatch.found) { + return response.conflict({ + body: { + message: i18n.translate('xpack.watcher.saveRoute.duplicateWatchIdErrorMessage', { + defaultMessage: "There is already a watch with ID '{watchId}'.", + values: { + watchId: id, + }, + }), + }, + }); + } + } catch (e) { + const es404 = isEsError(e) && e.statusCode === 404; + if (!es404) { + return response.internalError({ body: e }); + } + // Else continue... } - // Else continue... } - } - let serializedWatch; + let serializedWatch; - switch (type) { - case WATCH_TYPES.JSON: - const { name, watch } = watchConfig; - serializedWatch = serializeJsonWatch(name, watch); - break; + switch (type) { + case WATCH_TYPES.JSON: + const { name, watch } = watchConfig as any; + serializedWatch = serializeJsonWatch(name, watch); + break; - case WATCH_TYPES.THRESHOLD: - serializedWatch = serializeThresholdWatch(watchConfig); - break; - } - - try { - // Create new watch - return response.ok({ - body: await saveWatch(callWithRequest, id, serializedWatch), - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ statusCode: e.statusCode, body: e }); + case WATCH_TYPES.THRESHOLD: + serializedWatch = serializeThresholdWatch(watchConfig); + break; } - // Case: default - return response.internalError({ body: e }); - } - }; + try { + // Create new watch + return response.ok({ + body: await saveWatch(ctx.watcher!.client, id, serializedWatch), + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ statusCode: e.statusCode, body: e }); + } - deps.router.put( - { - path: '/api/watcher/watch/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - body: schema.object({}, { allowUnknowns: true }), - }, - }, - licensePreRoutingFactory(deps, handler) + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_visualize_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_visualize_route.ts index f2110bcc0ebdb..200b35953b6f2 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_visualize_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_visualize_route.ts @@ -5,8 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { IScopedClusterClient } from 'kibana/server'; import { isEsError } from '../../../lib/is_es_error'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; @@ -16,7 +15,12 @@ import { Watch } from '../../../models/watch/index'; // @ts-ignore import { VisualizeOptions } from '../../../models/visualize_options/index'; -function fetchVisualizeData(callWithRequest: any, index: any, body: any) { +const bodySchema = schema.object({ + watch: schema.object({}, { allowUnknowns: true }), + options: schema.object({}, { allowUnknowns: true }), +}); + +function fetchVisualizeData(dataClient: IScopedClusterClient, index: any, body: any) { const params = { index, body, @@ -25,46 +29,40 @@ function fetchVisualizeData(callWithRequest: any, index: any, body: any) { ignore: [404], }; - return callWithRequest('search', params); + return dataClient.callAsCurrentUser('search', params); } export function registerVisualizeRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - const watch = Watch.fromDownstreamJson(request.body.watch); - const options = VisualizeOptions.fromDownstreamJson(request.body.options); - const body = watch.getVisualizeQuery(options); - - try { - const hits = await fetchVisualizeData(callWithRequest, watch.index, body); - const visualizeData = watch.formatVisualizeData(hits); - - return response.ok({ - body: { - visualizeData, - }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ statusCode: e.statusCode, body: e }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; - deps.router.post( { path: '/api/watcher/watch/visualize', validate: { - body: schema.object({ - watch: schema.object({}, { allowUnknowns: true }), - options: schema.object({}, { allowUnknowns: true }), - }), + body: bodySchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + const watch = Watch.fromDownstreamJson(request.body.watch); + const options = VisualizeOptions.fromDownstreamJson(request.body.options); + const body = watch.getVisualizeQuery(options); + + try { + const hits = await fetchVisualizeData(ctx.watcher!.client, watch.index, body); + const visualizeData = watch.formatVisualizeData(hits); + + return response.ok({ + body: { + visualizeData, + }, + }); + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ statusCode: e.statusCode, body: e }); + } + + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watches/register_delete_route.ts b/x-pack/plugins/watcher/server/routes/api/watches/register_delete_route.ts index 2ac824529f9a6..71e0a77bff972 100644 --- a/x-pack/plugins/watcher/server/routes/api/watches/register_delete_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watches/register_delete_route.ts @@ -5,16 +5,20 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { IScopedClusterClient } from 'kibana/server'; import { RouteDependencies } from '../../../types'; import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; -function deleteWatches(callWithRequest: any, watchIds: string[]) { +const bodySchema = schema.object({ + watchIds: schema.arrayOf(schema.string()), +}); + +function deleteWatches(dataClient: IScopedClusterClient, watchIds: string[]) { const deletePromises = watchIds.map(watchId => { - return callWithRequest('watcher.deleteWatch', { - id: watchId, - }) + return dataClient + .callAsCurrentUser('watcher.deleteWatch', { + id: watchId, + }) .then((success: Array<{ _id: string }>) => ({ success })) .catch((error: Array<{ _id: string }>) => ({ error })); }); @@ -22,7 +26,7 @@ function deleteWatches(callWithRequest: any, watchIds: string[]) { return Promise.all(deletePromises).then(results => { const errors: Error[] = []; const successes: boolean[] = []; - results.forEach(({ success, error }) => { + results.forEach(({ success, error }: { success?: any; error?: any }) => { if (success) { successes.push(success._id); } else if (error) { @@ -38,26 +42,20 @@ function deleteWatches(callWithRequest: any, watchIds: string[]) { } export function registerDeleteRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - - try { - const results = await deleteWatches(callWithRequest, request.body.watchIds); - return response.ok({ body: { results } }); - } catch (e) { - return response.internalError({ body: e }); - } - }; - deps.router.post( { path: '/api/watcher/watches/delete', validate: { - body: schema.object({ - watchIds: schema.arrayOf(schema.string()), - }), + body: bodySchema, }, }, - licensePreRoutingFactory(deps, handler) + licensePreRoutingFactory(deps, async (ctx, request, response) => { + try { + const results = await deleteWatches(ctx.watcher!.client, request.body.watchIds); + return response.ok({ body: { results } }); + } catch (e) { + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/routes/api/watches/register_list_route.ts b/x-pack/plugins/watcher/server/routes/api/watches/register_list_route.ts index fcbdf688a2ab4..5e823a0a8d2de 100644 --- a/x-pack/plugins/watcher/server/routes/api/watches/register_list_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watches/register_list_route.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandler } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { get } from 'lodash'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { fetchAllFromScroll } from '../../../lib/fetch_all_from_scroll'; import { INDEX_NAMES, ES_SCROLL_SETTINGS } from '../../../../common/constants'; import { isEsError } from '../../../lib/is_es_error'; @@ -15,7 +14,7 @@ import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_facto // @ts-ignore import { Watch } from '../../../models/watch/index'; -function fetchWatches(callWithRequest: any) { +function fetchWatches(dataClient: IScopedClusterClient) { const params = { index: INDEX_NAMES.WATCHES, scroll: ES_SCROLL_SETTINGS.KEEPALIVE, @@ -25,62 +24,58 @@ function fetchWatches(callWithRequest: any) { ignore: [404], }; - return callWithRequest('search', params).then((response: any) => - fetchAllFromScroll(response, callWithRequest) - ); + return dataClient + .callAsCurrentUser('search', params) + .then((response: any) => fetchAllFromScroll(response, dataClient)); } export function registerListRoute(deps: RouteDependencies) { - const handler: RequestHandler = async (ctx, request, response) => { - const callWithRequest = callWithRequestFactory(deps.elasticsearchService, request); - - try { - const hits = await fetchWatches(callWithRequest); - const watches = hits.map((hit: any) => { - const id = get(hit, '_id'); - const watchJson = get(hit, '_source'); - const watchStatusJson = get(hit, '_source.status'); + deps.router.get( + { + path: '/api/watcher/watches', + validate: false, + }, + licensePreRoutingFactory(deps, async (ctx, request, response) => { + try { + const hits = await fetchWatches(ctx.watcher!.client); + const watches = hits.map((hit: any) => { + const id = get(hit, '_id'); + const watchJson = get(hit, '_source'); + const watchStatusJson = get(hit, '_source.status'); - return Watch.fromUpstreamJson( - { - id, - watchJson, - watchStatusJson, - }, - { - throwExceptions: { - Action: false, + return Watch.fromUpstreamJson( + { + id, + watchJson, + watchStatusJson, }, - } - ); - }); + { + throwExceptions: { + Action: false, + }, + } + ); + }); - return response.ok({ - body: { - watches: watches.map((watch: any) => watch.downstreamJson), - }, - }); - } catch (e) { - // Case: Error from Elasticsearch JS client - if (isEsError(e)) { - return response.customError({ - statusCode: e.statusCode, + return response.ok({ body: { - message: e.message, + watches: watches.map((watch: any) => watch.downstreamJson), }, }); - } - - // Case: default - return response.internalError({ body: e }); - } - }; + } catch (e) { + // Case: Error from Elasticsearch JS client + if (isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: { + message: e.message, + }, + }); + } - deps.router.get( - { - path: '/api/watcher/watches', - validate: false, - }, - licensePreRoutingFactory(deps, handler) + // Case: default + return response.internalError({ body: e }); + } + }) ); } diff --git a/x-pack/plugins/watcher/server/types.ts b/x-pack/plugins/watcher/server/types.ts index d9f2d3c3b1e7a..dd941054114a8 100644 --- a/x-pack/plugins/watcher/server/types.ts +++ b/x-pack/plugins/watcher/server/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter, ElasticsearchServiceSetup, IClusterClient } from 'kibana/server'; +import { IRouter } from 'kibana/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { XPackMainPlugin } from '../../../legacy/plugins/xpack_main/server/xpack_main'; @@ -24,8 +24,6 @@ export interface ServerShim { export interface RouteDependencies { router: IRouter; getLicenseStatus: () => LicenseStatus; - elasticsearchService: ElasticsearchServiceSetup; - elasticsearch: IClusterClient; } export interface LicenseStatus { diff --git a/x-pack/test/functional/apps/rollup_job/tsvb.js b/x-pack/test/functional/apps/rollup_job/tsvb.js index fb4a81406f9f1..acb73b170fbf2 100644 --- a/x-pack/test/functional/apps/rollup_job/tsvb.js +++ b/x-pack/test/functional/apps/rollup_job/tsvb.js @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import datemath from '@elastic/datemath'; import expect from '@kbn/expect'; import mockRolledUpData from './hybrid_index_helper'; @@ -12,7 +11,6 @@ export default function({ getService, getPageObjects }) { const es = getService('legacyEs'); const esArchiver = getService('esArchiver'); const retry = getService('retry'); - const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects([ 'common', 'settings', @@ -21,18 +19,16 @@ export default function({ getService, getPageObjects }) { 'timePicker', ]); - // FLAKY: https://github.com/elastic/kibana/issues/56816 - describe.skip('tsvb integration', function() { + describe('tsvb integration', function() { //Since rollups can only be created once with the same name (even if you delete it), //we add the Date.now() to avoid name collision if you run the tests locally back to back. const rollupJobName = `tsvb-test-rollup-job-${Date.now()}`; const rollupSourceIndexName = 'rollup-source-data'; const rollupTargetIndexName = `rollup-target-data`; - const now = new Date(); const pastDates = [ - datemath.parse('now-1m', { forceNow: now }), - datemath.parse('now-2m', { forceNow: now }), - datemath.parse('now-3m', { forceNow: now }), + new Date('October 15, 2019 05:35:32'), + new Date('October 15, 2019 05:34:32'), + new Date('October 15, 2019 05:33:32'), ]; before(async () => { @@ -78,10 +74,12 @@ export default function({ getService, getPageObjects }) { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisualBuilder(); await PageObjects.visualBuilder.checkVisualBuilderIsPresent(); - await PageObjects.timePicker.openQuickSelectTimeMenu(); - await testSubjects.click('superDatePickerCommonlyUsed_Last_24 hours'); await PageObjects.visualBuilder.clickMetric(); await PageObjects.visualBuilder.checkMetricTabIsPresent(); + await PageObjects.timePicker.setAbsoluteRange( + 'Oct 15, 2019 @ 00:00:01.000', + 'Oct 15, 2019 @ 19:31:44.000' + ); await PageObjects.visualBuilder.clickPanelOptions('metric'); await PageObjects.visualBuilder.setIndexPatternValue(rollupTargetIndexName); await PageObjects.visualBuilder.setIntervalValue('1d'); diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts new file mode 100644 index 0000000000000..f06dc0a14a383 --- /dev/null +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; +import { TransformPivotConfig } from '../../../../legacy/plugins/transform/public/app/common'; + +function getTransformConfig(): TransformPivotConfig { + const date = Date.now(); + return { + id: `ec_2_${date}`, + source: { index: ['ecommerce'] }, + pivot: { + group_by: { category: { terms: { field: 'category.keyword' } } }, + aggregations: { 'products.base_price.avg': { avg: { field: 'products.base_price' } } }, + }, + description: + 'ecommerce batch transform with avg(products.base_price) grouped by terms(category.keyword)', + dest: { index: `user-ec_2_${date}` }, + }; +} + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const transform = getService('transform'); + + describe('cloning', function() { + this.tags(['smoke']); + const transformConfig = getTransformConfig(); + + before(async () => { + await esArchiver.load('ml/ecommerce'); + await transform.api.createAndRunTransform(transformConfig); + await transform.securityUI.loginAsTransformPowerUser(); + }); + + after(async () => { + await esArchiver.unload('ml/ecommerce'); + await transform.api.deleteIndices(transformConfig.dest.index); + await transform.api.cleanTransformIndices(); + }); + + const testDataList = [ + { + suiteTitle: 'batch transform with terms group and avg agg', + expected: {}, + }, + ]; + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function() { + after(async () => { + // await transform.api.deleteIndices(); + }); + + it('loads the home page', async () => { + await transform.navigation.navigateTo(); + await transform.management.assertTransformListPageExists(); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 66a55105b3ca8..60b72f122f113 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -23,5 +23,6 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./creation_index_pattern')); loadTestFile(require.resolve('./creation_saved_search')); + loadTestFile(require.resolve('./cloning')); }); } diff --git a/x-pack/test/functional/services/transform_ui/api.ts b/x-pack/test/functional/services/transform_ui/api.ts index a6756e5940d72..6a4a1dfff6ea1 100644 --- a/x-pack/test/functional/services/transform_ui/api.ts +++ b/x-pack/test/functional/services/transform_ui/api.ts @@ -7,10 +7,17 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { + TRANSFORM_STATE, + TransformPivotConfig, + TransformStats, +} from '../../../../legacy/plugins/transform/public/app/common'; + export function TransformAPIProvider({ getService }: FtrProviderContext) { const es = getService('legacyEs'); const log = getService('log'); const retry = getService('retry'); + const esSupertest = getService('esSupertest'); return { async deleteIndices(indices: string) { @@ -39,5 +46,89 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { async cleanTransformIndices() { await this.deleteIndices('.transform-*'); }, + + async getTransformStats(transformId: string): Promise { + log.debug(`Fetching transform stats for transform ${transformId}`); + const statsResponse = await esSupertest + .get(`/_transform/${transformId}/_stats`) + .expect(200) + .then((res: any) => res.body); + + expect(statsResponse.transforms).to.have.length(1); + return statsResponse.transforms[0]; + }, + + async getTransformState(transformId: string): Promise { + const stats = await this.getTransformStats(transformId); + const state: TRANSFORM_STATE = stats.state; + + return state; + }, + + async waitForTransformState(transformId: string, expectedState: TRANSFORM_STATE) { + await retry.waitForWithTimeout( + `transform state to be ${expectedState}`, + 2 * 60 * 1000, + async () => { + const state = await this.getTransformState(transformId); + if (state === expectedState) { + return true; + } else { + throw new Error(`expected transform state to be ${expectedState} but got ${state}`); + } + } + ); + }, + + async waitForBatchTransformToComplete(transformId: string) { + await retry.waitForWithTimeout(`batch transform to complete`, 2 * 60 * 1000, async () => { + const stats = await this.getTransformStats(transformId); + if (stats.state === TRANSFORM_STATE.STOPPED && stats.checkpointing.last.checkpoint === 1) { + return true; + } else { + throw new Error( + `expected batch transform to be stopped with last checkpoint = 1 (got status: '${stats.state}', checkpoint: '${stats.checkpointing.last.checkpoint}')` + ); + } + }); + }, + + async getTransform(transformId: string) { + return await esSupertest.get(`/_transform/${transformId}`).expect(200); + }, + + async createTransform(transformConfig: TransformPivotConfig) { + const transformId = transformConfig.id; + log.debug(`Creating transform with id '${transformId}'...`); + await esSupertest + .put(`/_transform/${transformId}`) + .send(transformConfig) + .expect(200); + + await retry.waitForWithTimeout(`'${transformId}' to be created`, 5 * 1000, async () => { + if (await this.getTransform(transformId)) { + return true; + } else { + throw new Error(`expected transform '${transformId}' to be created`); + } + }); + }, + + async startTransform(transformId: string) { + log.debug(`Starting transform '${transformId}' ...`); + await esSupertest.post(`/_transform/${transformId}/_start`).expect(200); + }, + + async createAndRunTransform(transformConfig: TransformPivotConfig) { + await this.createTransform(transformConfig); + await this.startTransform(transformConfig.id); + if (transformConfig.sync === undefined) { + // batch mode + await this.waitForBatchTransformToComplete(transformConfig.id); + } else { + // continuous mode + await this.waitForTransformState(transformConfig.id, TRANSFORM_STATE.STARTED); + } + }, }; }