diff --git a/docs/discover/kuery.asciidoc b/docs/discover/kuery.asciidoc index f306f2b8f763f..35f1160ee834d 100644 --- a/docs/discover/kuery.asciidoc +++ b/docs/discover/kuery.asciidoc @@ -115,7 +115,7 @@ KQL supports `>`, `>=`, `<`, and `<=`. For example: [source,yaml] ------------------- -account_number:>=100 and items_sold:<=200 +account_number >= 100 and items_sold <= 200 ------------------- [discrete] diff --git a/docs/user/dashboard/dashboard-drilldown.asciidoc b/docs/user/dashboard/dashboard-drilldown.asciidoc index 5e928fd731bb4..bdff7355d7467 100644 --- a/docs/user/dashboard/dashboard-drilldown.asciidoc +++ b/docs/user/dashboard/dashboard-drilldown.asciidoc @@ -57,8 +57,8 @@ TIP: If you don’t see data for a panel, try changing the time range. . Set a search and filter. + [%hardbreaks] -Search: `extension.keyword:( “gz” or “css” or “deb”)` -Filter: `geo.src : CN` +Search: `extension.keyword: ("gz" or "css" or "deb")` +Filter: `geo.src: CN` *Create the drilldown* @@ -94,4 +94,3 @@ image::images/drilldown_on_panel.png[Drilldown on pie chart that navigates to an + You are navigated to your destination dashboard. Verify that the search query, filters, and time range are carried over. - diff --git a/docs/user/monitoring/kibana-alerts.asciidoc b/docs/user/monitoring/kibana-alerts.asciidoc index 1ac5c385f8ed5..300497126c3e5 100644 --- a/docs/user/monitoring/kibana-alerts.asciidoc +++ b/docs/user/monitoring/kibana-alerts.asciidoc @@ -31,6 +31,33 @@ default, the trigger condition is set at 85% or more averaged over the last 5 minutes. The alert is grouped across all the nodes of the cluster by running checks on a schedule time of 1 minute with a re-notify internal of 1 day. +[discrete] +[[kibana-alerts-disk-usage-threshold]] +== Disk usage threshold + +This alert is triggered when a node is nearly at disk capacity. By +default, the trigger condition is set at 80% or more averaged over the last 5 +minutes. The alert is grouped across all the nodes of the cluster by running +checks on a schedule time of 1 minute with a re-notify internal of 1 day. + +[discrete] +[[kibana-alerts-jvm-memory-threshold]] +== JVM memory threshold + +This alert is triggered when a node runs a consistently high JVM memory usage. By +default, the trigger condition is set at 85% or more averaged over the last 5 +minutes. The alert is grouped across all the nodes of the cluster by running +checks on a schedule time of 1 minute with a re-notify internal of 1 day. + +[discrete] +[[kibana-alerts-missing-monitoring-data]] +== Missing monitoring data + +This alert is triggered when any stack products nodes or instances stop sending +monitoring data. By default, the trigger condition is set to missing for 15 minutes +looking back 1 day. The alert is grouped across all the nodes of the cluster by running +checks on a schedule time of 1 minute with a re-notify internal of 6 hours. + NOTE: Some action types are subscription features, while others are free. For a comparison of the Elastic subscription levels, see the alerting section of the {subscriptions}[Subscriptions page]. diff --git a/package.json b/package.json index b965f50cd468c..84e9c0e2762eb 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ }, "dependencies": { "@elastic/datemath": "5.0.3", - "@elastic/elasticsearch": "7.9.1", + "@elastic/elasticsearch": "7.10.0-rc.1", "@elastic/eui": "29.5.0", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", @@ -124,6 +124,7 @@ "@elastic/safer-lodash-set": "0.0.0", "@hapi/good-squeeze": "5.2.1", "@hapi/wreck": "^15.0.2", + "@kbn/ace": "1.0.0", "@kbn/analytics": "1.0.0", "@kbn/apm-config-loader": "1.0.0", "@kbn/config": "1.0.0", @@ -131,11 +132,11 @@ "@kbn/i18n": "1.0.0", "@kbn/interpreter": "1.0.0", "@kbn/logging": "1.0.0", + "@kbn/monaco": "1.0.0", "@kbn/std": "1.0.0", "@kbn/ui-framework": "1.0.0", - "@kbn/ace": "1.0.0", - "@kbn/monaco": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", + "@types/pdfmake": "^0.1.15", "@types/yauzl": "^2.9.1", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index 6ed3ae2eb2fb7..0f9b917e7f05a 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -12,7 +12,7 @@ "kbn:watch": "node scripts/build --watch" }, "dependencies": { - "@elastic/elasticsearch": "7.9.1", + "@elastic/elasticsearch": "7.10.0-rc.1", "@kbn/dev-utils": "1.0.0", "abort-controller": "^3.0.0", "chalk": "^4.1.0", diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index a2c4e1e2134e7..1f86122d7e129 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -4,6 +4,9 @@ "private": true, "description": "Just some helpers for kibana plugin devs.", "license": "Apache-2.0", + "kibana": { + "devOnly": true + }, "main": "target/index.js", "bin": { "plugin-helpers": "bin/plugin-helpers.js" diff --git a/packages/kbn-spec-to-console/package.json b/packages/kbn-spec-to-console/package.json index 557f38ec740fc..9684201c72384 100644 --- a/packages/kbn-spec-to-console/package.json +++ b/packages/kbn-spec-to-console/package.json @@ -12,6 +12,9 @@ }, "author": "", "license": "Apache-2.0", + "kibana": { + "devOnly": true + }, "bugs": { "url": "https://github.com/jbudz/spec-to-console/issues" }, diff --git a/packages/kbn-storybook/lib/default_config.ts b/packages/kbn-storybook/lib/default_config.ts index dc2647b7b5757..c3bc65059d4a6 100644 --- a/packages/kbn-storybook/lib/default_config.ts +++ b/packages/kbn-storybook/lib/default_config.ts @@ -20,12 +20,7 @@ import { StorybookConfig } from '@storybook/core/types'; export const defaultConfig: StorybookConfig = { - addons: [ - '@kbn/storybook/preset', - '@storybook/addon-a11y', - '@storybook/addon-knobs', - '@storybook/addon-essentials', - ], + addons: ['@kbn/storybook/preset', '@storybook/addon-a11y', '@storybook/addon-essentials'], stories: ['../**/*.stories.tsx'], typescript: { reactDocgen: false, diff --git a/packages/kbn-test/src/jest/integration_tests/junit_reporter.test.ts b/packages/kbn-test/src/jest/integration_tests/junit_reporter.test.ts index 1390c44d84a07..a95215a0044f1 100644 --- a/packages/kbn-test/src/jest/integration_tests/junit_reporter.test.ts +++ b/packages/kbn-test/src/jest/integration_tests/junit_reporter.test.ts @@ -24,13 +24,13 @@ import { readFileSync } from 'fs'; import del from 'del'; import execa from 'execa'; import xml2js from 'xml2js'; -import { makeJunitReportPath } from '@kbn/test'; +import { getUniqueJunitReportPath } from '@kbn/test'; import { REPO_ROOT } from '@kbn/utils'; const MINUTE = 1000 * 60; const FIXTURE_DIR = resolve(__dirname, '__fixtures__'); const TARGET_DIR = resolve(FIXTURE_DIR, 'target'); -const XML_PATH = makeJunitReportPath(FIXTURE_DIR, 'JUnit Reporter Integration Test'); +const XML_PATH = getUniqueJunitReportPath(FIXTURE_DIR, 'JUnit Reporter Integration Test'); afterAll(async () => { await del(TARGET_DIR); diff --git a/packages/kbn-test/src/jest/junit_reporter.ts b/packages/kbn-test/src/jest/junit_reporter.ts index 0712584122e05..b6e964c22adfc 100644 --- a/packages/kbn-test/src/jest/junit_reporter.ts +++ b/packages/kbn-test/src/jest/junit_reporter.ts @@ -27,7 +27,7 @@ import type { Config } from '@jest/types'; import { AggregatedResult, Test, BaseReporter } from '@jest/reporters'; import { escapeCdata } from '../mocha/xml'; -import { makeJunitReportPath } from './report_path'; +import { getUniqueJunitReportPath } from './report_path'; interface ReporterOptions { reportName?: string; @@ -115,7 +115,7 @@ export default class JestJUnitReporter extends BaseReporter { }); }); - const reportPath = makeJunitReportPath(rootDirectory, reportName); + const reportPath = getUniqueJunitReportPath(rootDirectory, reportName); const reportXML = root.end(); mkdirSync(dirname(reportPath), { recursive: true }); writeFileSync(reportPath, reportXML, 'utf8'); diff --git a/packages/kbn-test/src/jest/report_path.ts b/packages/kbn-test/src/jest/report_path.ts index fe122c349c193..c9cf3ce454e6a 100644 --- a/packages/kbn-test/src/jest/report_path.ts +++ b/packages/kbn-test/src/jest/report_path.ts @@ -17,14 +17,24 @@ * under the License. */ -import { resolve } from 'path'; +import Fs from 'fs'; +import Path from 'path'; + import { CI_PARALLEL_PROCESS_PREFIX } from '../ci_parallel_process_prefix'; -export function makeJunitReportPath(rootDirectory: string, reportName: string) { - return resolve( +export function getUniqueJunitReportPath( + rootDirectory: string, + reportName: string, + counter?: number +): string { + const path = Path.resolve( rootDirectory, 'target/junit', process.env.JOB || '.', - `TEST-${CI_PARALLEL_PROCESS_PREFIX}${reportName}.xml` + `TEST-${CI_PARALLEL_PROCESS_PREFIX}${reportName}${counter ? `-${counter}` : ''}.xml` ); + + return Fs.existsSync(path) + ? getUniqueJunitReportPath(rootDirectory, reportName, (counter ?? 0) + 1) + : path; } diff --git a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js b/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js index 00a11432dd9e8..dc7d161eca5a3 100644 --- a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js @@ -25,13 +25,14 @@ import { parseString } from 'xml2js'; import del from 'del'; import Mocha from 'mocha'; import expect from '@kbn/expect'; -import { makeJunitReportPath } from '@kbn/test'; +import { getUniqueJunitReportPath } from '@kbn/test'; import { setupJUnitReportGeneration } from '../junit_report_generation'; const PROJECT_DIR = resolve(__dirname, 'fixtures/project'); const DURATION_REGEX = /^\d+\.\d{3}$/; const ISO_DATE_SEC_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; +const XML_PATH = getUniqueJunitReportPath(PROJECT_DIR, 'test'); describe('dev/mocha/junit report generation', () => { afterEach(() => { @@ -50,9 +51,7 @@ describe('dev/mocha/junit report generation', () => { mocha.addFile(resolve(PROJECT_DIR, 'test.js')); await new Promise((resolve) => mocha.run(resolve)); - const report = await fcb((cb) => - parseString(readFileSync(makeJunitReportPath(PROJECT_DIR, 'test')), cb) - ); + const report = await fcb((cb) => parseString(readFileSync(XML_PATH), cb)); // test case results are wrapped in expect(report).to.eql({ diff --git a/packages/kbn-test/src/mocha/junit_report_generation.js b/packages/kbn-test/src/mocha/junit_report_generation.js index 7e39c32ee4db8..9ac9bd18548f4 100644 --- a/packages/kbn-test/src/mocha/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.js @@ -22,7 +22,7 @@ import { writeFileSync, mkdirSync } from 'fs'; import { inspect } from 'util'; import xmlBuilder from 'xmlbuilder'; -import { makeJunitReportPath } from '@kbn/test'; +import { getUniqueJunitReportPath } from '@kbn/test'; import { getSnapshotOfRunnableLogs } from './log_cache'; import { escapeCdata } from '../'; @@ -140,7 +140,7 @@ export function setupJUnitReportGeneration(runner, options = {}) { } }); - const reportPath = makeJunitReportPath(rootDirectory, reportName); + const reportPath = getUniqueJunitReportPath(rootDirectory, reportName); const reportXML = builder.end(); mkdirSync(dirname(reportPath), { recursive: true }); writeFileSync(reportPath, reportXML, 'utf8'); diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile index fba3dcee0a7cb..bd30efcb1c6d3 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile @@ -53,9 +53,15 @@ RUN for iter in {1..10}; do \ (exit $exit_code) # Add an init process, check the checksum to make sure it's a match -RUN curl -L -o /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 -RUN echo "37f2c1f0372a45554f1b89924fbb134fc24c3756efaedf11e07f599494e0eff9 /usr/local/bin/dumb-init" | sha256sum -c - -RUN chmod +x /usr/local/bin/dumb-init +RUN set -e ; \ + TINI_VERSION='v0.19.0' ; \ + TINI_BIN='tini-amd64' ; \ + curl --retry 8 -S -L -O "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${TINI_BIN}" ; \ + curl --retry 8 -S -L -O "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${TINI_BIN}.sha256sum" ; \ + sha256sum -c "${TINI_BIN}.sha256sum" ; \ + rm "${TINI_BIN}.sha256sum" ; \ + mv "${TINI_BIN}" /bin/tini ; \ + chmod +x /bin/tini RUN mkdir /usr/share/fonts/local RUN curl -L -o /usr/share/fonts/local/NotoSansCJK-Regular.ttc https://github.com/googlefonts/noto-cjk/raw/NotoSansV2.001/NotoSansCJK-Regular.ttc @@ -125,6 +131,6 @@ RUN mkdir /licenses && \ USER kibana -ENTRYPOINT ["/usr/local/bin/dumb-init", "--"] +ENTRYPOINT ["/bin/tini", "--"] CMD ["/usr/local/bin/kibana-docker"] diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts index 9fd43be8dc5b3..9085ae07bbe3e 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts @@ -44,7 +44,6 @@ function create(id: string) { return new IndexPattern({ spec: { id, type, version, timeFieldName, fields, title }, - savedObjectsClient: {} as any, fieldFormats: fieldFormatsMock, shortDotsEnable: false, metaFields: [], @@ -214,7 +213,6 @@ describe('IndexPattern', () => { const spec = indexPattern.toSpec(); const restoredPattern = new IndexPattern({ spec, - savedObjectsClient: {} as any, fieldFormats: fieldFormatsMock, shortDotsEnable: false, metaFields: [], diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index d38df68e9f428..a0f27078543a9 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -18,7 +18,6 @@ */ import _, { each, reject } from 'lodash'; -import { SavedObjectsClientCommon } from '../..'; import { DuplicateField } from '../../../../kibana_utils/common'; import { ES_FIELD_TYPES, KBN_FIELD_TYPES, IIndexPattern, IFieldType } from '../../../common'; @@ -31,10 +30,9 @@ import { SerializedFieldFormat } from '../../../../expressions/common'; interface IndexPatternDeps { spec?: IndexPatternSpec; - savedObjectsClient: SavedObjectsClientCommon; fieldFormats: FieldFormatsStartCommon; - shortDotsEnable: boolean; - metaFields: string[]; + shortDotsEnable?: boolean; + metaFields?: string[]; } interface SavedObjectBody { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index bfd0dc9d946c2..fd3d7a1d138fd 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -435,7 +435,6 @@ export class IndexPatternsService { const indexPattern = new IndexPattern({ spec, - savedObjectsClient: this.savedObjectsClient, fieldFormats: this.fieldFormats, shortDotsEnable, metaFields, diff --git a/src/plugins/data/common/search/tabify/__snapshots__/tabify_docs.test.ts.snap b/src/plugins/data/common/search/tabify/__snapshots__/tabify_docs.test.ts.snap new file mode 100644 index 0000000000000..d5ddaa31b8ac3 --- /dev/null +++ b/src/plugins/data/common/search/tabify/__snapshots__/tabify_docs.test.ts.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`tabifyDocs converts fields by default 1`] = ` +Object { + "columns": Array [ + Object { + "id": "fieldTest", + "meta": Object { + "field": "fieldTest", + "index": "test-index", + "params": Object { + "id": "number", + }, + "type": "number", + }, + "name": "fieldTest", + }, + Object { + "id": "invalidMapping", + "meta": Object { + "field": "invalidMapping", + "index": "test-index", + "params": undefined, + "type": "number", + }, + "name": "invalidMapping", + }, + Object { + "id": "nested.field", + "meta": Object { + "field": "nested.field", + "index": "test-index", + "params": Object { + "id": "number", + }, + "type": "number", + }, + "name": "nested.field", + }, + ], + "rows": Array [ + Object { + "fieldTest": 123, + "invalidMapping": 345, + "nested.field": 123, + }, + ], + "type": "datatable", +} +`; + +exports[`tabifyDocs converts source if option is set 1`] = ` +Object { + "columns": Array [ + Object { + "id": "sourceTest", + "meta": Object { + "field": "sourceTest", + "index": "test-index", + "params": Object { + "id": "number", + }, + "type": "number", + }, + "name": "sourceTest", + }, + ], + "rows": Array [ + Object { + "sourceTest": 123, + }, + ], + "type": "datatable", +} +`; + +exports[`tabifyDocs skips nested fields if option is set 1`] = ` +Object { + "columns": Array [ + Object { + "id": "fieldTest", + "meta": Object { + "field": "fieldTest", + "index": "test-index", + "params": Object { + "id": "number", + }, + "type": "number", + }, + "name": "fieldTest", + }, + Object { + "id": "invalidMapping", + "meta": Object { + "field": "invalidMapping", + "index": "test-index", + "params": undefined, + "type": "number", + }, + "name": "invalidMapping", + }, + Object { + "id": "nested", + "meta": Object { + "field": "nested", + "index": "test-index", + "params": undefined, + "type": "object", + }, + "name": "nested", + }, + ], + "rows": Array [ + Object { + "fieldTest": 123, + "invalidMapping": 345, + "nested": Array [ + Object { + "field": 123, + }, + ], + }, + ], + "type": "datatable", +} +`; + +exports[`tabifyDocs works without provided index pattern 1`] = ` +Object { + "columns": Array [ + Object { + "id": "fieldTest", + "meta": Object { + "field": "fieldTest", + "index": undefined, + "params": undefined, + "type": "number", + }, + "name": "fieldTest", + }, + Object { + "id": "invalidMapping", + "meta": Object { + "field": "invalidMapping", + "index": undefined, + "params": undefined, + "type": "number", + }, + "name": "invalidMapping", + }, + Object { + "id": "nested.field", + "meta": Object { + "field": "nested.field", + "index": undefined, + "params": undefined, + "type": "number", + }, + "name": "nested.field", + }, + ], + "rows": Array [ + Object { + "fieldTest": 123, + "invalidMapping": 345, + "nested.field": 123, + }, + ], + "type": "datatable", +} +`; diff --git a/src/plugins/data/common/search/tabify/index.ts b/src/plugins/data/common/search/tabify/index.ts index 90ac3f2fb730b..9e6657f5e8d83 100644 --- a/src/plugins/data/common/search/tabify/index.ts +++ b/src/plugins/data/common/search/tabify/index.ts @@ -17,6 +17,26 @@ * under the License. */ +import { SearchResponse } from 'elasticsearch'; +import { SearchSource } from '../search_source'; +import { tabifyAggResponse } from './tabify'; +import { tabifyDocs, TabifyDocsOptions } from './tabify_docs'; +import { TabbedResponseWriterOptions } from './types'; + +export const tabify = ( + searchSource: SearchSource, + esResponse: SearchResponse, + opts: Partial | TabifyDocsOptions +) => { + return !esResponse.aggregations + ? tabifyDocs(esResponse, searchSource.getField('index'), opts as TabifyDocsOptions) + : tabifyAggResponse( + searchSource.getField('aggs'), + esResponse, + opts as Partial + ); +}; + export { tabifyAggResponse } from './tabify'; export { tabifyGetColumns } from './get_columns'; diff --git a/src/plugins/data/common/search/tabify/tabify_docs.test.ts b/src/plugins/data/common/search/tabify/tabify_docs.test.ts new file mode 100644 index 0000000000000..a1218928561c6 --- /dev/null +++ b/src/plugins/data/common/search/tabify/tabify_docs.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { tabifyDocs } from './tabify_docs'; +import { IndexPattern } from '../../index_patterns/index_patterns'; +import { SearchResponse } from 'elasticsearch'; + +describe('tabifyDocs', () => { + const fieldFormats = { + getInstance: (id: string) => ({ toJSON: () => ({ id }) }), + getDefaultInstance: (id: string) => ({ toJSON: () => ({ id }) }), + }; + + const index = new IndexPattern({ + spec: { + id: 'test-index', + fields: { + sourceTest: { name: 'sourceTest', type: 'number', searchable: true, aggregatable: true }, + fieldTest: { name: 'fieldTest', type: 'number', searchable: true, aggregatable: true }, + 'nested.field': { + name: 'nested.field', + type: 'number', + searchable: true, + aggregatable: true, + }, + }, + }, + fieldFormats: fieldFormats as any, + }); + + const response = { + hits: { + hits: [ + { + _source: { sourceTest: 123 }, + fields: { fieldTest: 123, invalidMapping: 345, nested: [{ field: 123 }] }, + }, + ], + }, + } as SearchResponse; + + it('converts fields by default', () => { + const table = tabifyDocs(response, index); + expect(table).toMatchSnapshot(); + }); + + it('converts source if option is set', () => { + const table = tabifyDocs(response, index, { source: true }); + expect(table).toMatchSnapshot(); + }); + + it('skips nested fields if option is set', () => { + const table = tabifyDocs(response, index, { shallow: true }); + expect(table).toMatchSnapshot(); + }); + + it('works without provided index pattern', () => { + const table = tabifyDocs(response); + expect(table).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data/common/search/tabify/tabify_docs.ts b/src/plugins/data/common/search/tabify/tabify_docs.ts new file mode 100644 index 0000000000000..78ebee9e65717 --- /dev/null +++ b/src/plugins/data/common/search/tabify/tabify_docs.ts @@ -0,0 +1,113 @@ +/* + * 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 { SearchResponse } from 'elasticsearch'; +import { isPlainObject } from 'lodash'; +import { IndexPattern } from '../../index_patterns/index_patterns'; +import { Datatable, DatatableColumn, DatatableColumnType } from '../../../../expressions/common'; + +export function flattenHit( + hit: Record, + indexPattern?: IndexPattern, + shallow: boolean = false +) { + const flat = {} as Record; + + function flatten(obj: Record, keyPrefix: string = '') { + for (const [k, val] of Object.entries(obj)) { + const key = keyPrefix + k; + + const field = indexPattern?.fields.getByName(key); + + if (!shallow) { + const isNestedField = field?.type === 'nested'; + if (Array.isArray(val) && !isNestedField) { + val.forEach((v) => isPlainObject(v) && flatten(v, key + '.')); + continue; + } + } else if (flat[key] !== undefined) { + continue; + } + + const hasValidMapping = field?.type !== 'conflict'; + const isValue = !isPlainObject(val); + + if (hasValidMapping || isValue) { + if (!flat[key]) { + flat[key] = val; + } else if (Array.isArray(flat[key])) { + flat[key].push(val); + } else { + flat[key] = [flat[key], val]; + } + continue; + } + + flatten(val, key + '.'); + } + } + + flatten(hit); + return flat; +} + +export interface TabifyDocsOptions { + shallow?: boolean; + source?: boolean; +} + +export const tabifyDocs = ( + esResponse: SearchResponse, + index?: IndexPattern, + params: TabifyDocsOptions = {} +): Datatable => { + const columns: DatatableColumn[] = []; + + const rows = esResponse.hits.hits + .map((hit) => { + const toConvert = params.source ? hit._source : hit.fields; + const flat = flattenHit(toConvert, index, params.shallow); + for (const [key, value] of Object.entries(flat)) { + const field = index?.fields.getByName(key); + const fieldName = field?.name || key; + if (!columns.find((c) => c.id === fieldName)) { + const fieldType = (field?.type as DatatableColumnType) || typeof value; + const formatter = field && index?.getFormatterForField(field); + columns.push({ + id: fieldName, + name: fieldName, + meta: { + type: fieldType, + field: fieldName, + index: index?.id, + params: formatter ? formatter.toJSON() : undefined, + }, + }); + } + } + return flat; + }) + .filter((hit) => hit); + + return { + type: 'datatable', + columns, + rows, + }; +}; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 1390b28ec830d..d2439e3f1573c 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2283,7 +2283,7 @@ export const UI_SETTINGS: { // src/plugins/data/common/es_query/filters/meta_filter.ts:54:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrase_filter.ts:33:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrases_filter.ts:31:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:64:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:62:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts // src/plugins/data/common/search/aggs/types.ts:98:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts // src/plugins/data/public/field_formats/field_formats_service.ts:67:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 65313adfc0e0f..3143d5baa5b77 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -1113,8 +1113,8 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // // src/plugins/data/common/es_query/filters/meta_filter.ts:53:3 - (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:54:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:58:45 - (ae-forgotten-export) The symbol "IndexPatternFieldMap" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:64:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:56:45 - (ae-forgotten-export) The symbol "IndexPatternFieldMap" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:62:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildCustomFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/embeddable/public/components/panel_options_menu/__examples__/panel_options_menu.stories.tsx b/src/plugins/embeddable/public/components/panel_options_menu/__examples__/panel_options_menu.stories.tsx index 33724068a6ba8..551caa28d2291 100644 --- a/src/plugins/embeddable/public/components/panel_options_menu/__examples__/panel_options_menu.stories.tsx +++ b/src/plugins/embeddable/public/components/panel_options_menu/__examples__/panel_options_menu.stories.tsx @@ -17,37 +17,45 @@ * under the License. */ -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { withKnobs, boolean } from '@storybook/addon-knobs'; +import * as React from 'react'; import { PanelOptionsMenu } from '..'; -const euiContextDescriptors = { - id: 'mainMenu', - title: 'Options', - items: [ - { - name: 'Inspect', - icon: 'inspect', - onClick: action('onClick(inspect)'), - }, - { - name: 'Full screen', - icon: 'expand', - onClick: action('onClick(expand)'), +export default { + title: 'components/PanelOptionsMenu', + component: PanelOptionsMenu, + argTypes: { + isViewMode: { + control: { type: 'boolean' }, }, + }, + decorators: [ + (Story: React.ComponentType) => ( +
+ +
+ ), ], }; -storiesOf('components/PanelOptionsMenu', module) - .addDecorator(withKnobs) - .add('default', () => { - const isViewMode = boolean('isViewMode', false); +export function Default({ isViewMode }: React.ComponentProps) { + const euiContextDescriptors = { + id: 'mainMenu', + title: 'Options', + items: [ + { + name: 'Inspect', + icon: 'inspect', + onClick: action('onClick(inspect)'), + }, + { + name: 'Full screen', + icon: 'expand', + onClick: action('onClick(expand)'), + }, + ], + }; - return ( -
- -
- ); - }); + return ; +} +Default.args = { isViewMode: false } as React.ComponentProps; diff --git a/src/plugins/kibana_usage_collection/tsconfig.json b/src/plugins/kibana_usage_collection/tsconfig.json new file mode 100644 index 0000000000000..d664d936f6667 --- /dev/null +++ b/src/plugins/kibana_usage_collection/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/*", + "server/**/**/*", + "../../../typings/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../../plugins/usage_collection/tsconfig.json" }, + ] +} diff --git a/src/plugins/newsfeed/tsconfig.json b/src/plugins/newsfeed/tsconfig.json new file mode 100644 index 0000000000000..66244a22336c7 --- /dev/null +++ b/src/plugins/newsfeed/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + "common/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" } + ] +} diff --git a/tasks/config/run.js b/tasks/config/run.js index eddcb0bdd59d0..e96011816ed4d 100644 --- a/tasks/config/run.js +++ b/tasks/config/run.js @@ -177,6 +177,10 @@ module.exports = function () { 'test/server_integration/http/ssl_redirect/config.js', '--config', 'test/server_integration/http/cache/config.js', + '--config', + 'test/server_integration/http/ssl_with_p12/config.js', + '--config', + 'test/server_integration/http/ssl_with_p12_intermediate/config.js', '--bail', '--debug', '--kibana-install-dir', diff --git a/test/functional/apps/discover/_field_data.js b/test/functional/apps/discover/_field_data.js index d45b8f4841cb6..118234d54626c 100644 --- a/test/functional/apps/discover/_field_data.js +++ b/test/functional/apps/discover/_field_data.js @@ -27,7 +27,8 @@ export default function ({ getService, getPageObjects }) { const queryBar = getService('queryBar'); const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']); - describe('discover tab', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/78689 + describe.skip('discover tab', function describeIndexTests() { this.tags('includeFirefox'); before(async function () { await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/test/tsconfig.json b/test/tsconfig.json index ec6612d0dd00d..a6cc2d34639b7 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -23,6 +23,8 @@ { "path": "../src/plugins/kibana_react/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, { "path": "../src/plugins/telemetry_collection_manager/tsconfig.json" }, - { "path": "../src/plugins/telemetry/tsconfig.json" } + { "path": "../src/plugins/telemetry/tsconfig.json" }, + { "path": "../src/plugins/kibana_usage_collection/tsconfig.json" }, + { "path": "../src/plugins/newsfeed/tsconfig.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index 4f22168b67a09..73646291e3d08 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,9 @@ "src/plugins/kibana_react/**/*", "src/plugins/usage_collection/**/*", "src/plugins/telemetry_collection_manager/**/*", - "src/plugins/telemetry/**/*" + "src/plugins/telemetry/**/*", + "src/plugins/kibana_usage_collection/**/*", + "src/plugins/newsfeed/**/*" // In the build we actually exclude **/public/**/* from this config so that // we can run the TSC on both this and the .browser version of this config // file, but if we did it during development IDEs would not be able to find @@ -27,6 +29,8 @@ { "path": "./src/plugins/kibana_react/tsconfig.json" }, { "path": "./src/plugins/usage_collection/tsconfig.json" }, { "path": "./src/plugins/telemetry_collection_manager/tsconfig.json" }, - { "path": "./src/plugins/telemetry/tsconfig.json" } + { "path": "./src/plugins/telemetry/tsconfig.json" }, + { "path": "./src/plugins/kibana_usage_collection/tsconfig.json" }, + { "path": "./src/plugins/newsfeed/tsconfig.json" } ] } diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 4b6ed4a0db813..bb1bdc08cafd6 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -8,5 +8,7 @@ { "path": "./src/plugins/usage_collection/tsconfig.json" }, { "path": "./src/plugins/telemetry_collection_manager/tsconfig.json" }, { "path": "./src/plugins/telemetry/tsconfig.json" }, + { "path": "./src/plugins/kibana_usage_collection/tsconfig.json" }, + { "path": "./src/plugins/newsfeed/tsconfig.json" }, ] } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx index a5393995f0864..0e517f6bf6212 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx @@ -4,27 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { boolean, withKnobs } from '@storybook/addon-knobs'; -import { storiesOf } from '@storybook/react'; -import React from 'react'; +import React, { ComponentProps } from 'react'; import { SyncBadge } from './SyncBadge'; -storiesOf('app/TransactionDetails/SyncBadge', module) - .addDecorator(withKnobs) - .add( - 'example', - () => { - return ; +export default { + title: 'app/TransactionDetails/SyncBadge', + component: SyncBadge, + argTypes: { + sync: { + control: { type: 'inline-radio', options: [true, false, undefined] }, }, - { - showPanel: true, - info: { source: false }, - } - ) - .add( - 'sync=undefined', - () => { - return ; - }, - { info: { source: false } } - ); + }, +}; + +export function Example({ sync }: ComponentProps) { + return ; +} +Example.args = { sync: true } as ComponentProps; diff --git a/x-pack/plugins/apm/scripts/package.json b/x-pack/plugins/apm/scripts/package.json index c68dc49cd9370..3549f378efced 100644 --- a/x-pack/plugins/apm/scripts/package.json +++ b/x-pack/plugins/apm/scripts/package.json @@ -4,7 +4,7 @@ "main": "index.js", "license": "MIT", "dependencies": { - "@elastic/elasticsearch": "7.9.1", + "@elastic/elasticsearch": "7.10.0-rc.1", "@octokit/rest": "^16.35.0", "console-stamp": "^0.2.9", "hdr-histogram-js": "^1.2.0" diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts index 2c833bcfeaf4c..1516aa9096eca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -28,6 +28,7 @@ jest.mock('react-router-dom', () => ({ ...(jest.requireActual('react-router-dom') as object), useHistory: jest.fn(() => mockHistory), useLocation: jest.fn(() => mockLocation), + useParams: jest.fn(() => ({})), })); /** diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/index.ts deleted file mode 100644 index 05c60ebced088..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export { useDidUpdateEffect } from './use_did_update_effect'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.test.tsx deleted file mode 100644 index e3d2ffb44f01e..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState } from 'react'; -import { mount } from 'enzyme'; - -import { EuiLink } from '@elastic/eui'; - -import { useDidUpdateEffect } from './use_did_update_effect'; - -const fn = jest.fn(); - -const TestHook = ({ value }: { value: number }) => { - const [inputValue, setValue] = useState(value); - useDidUpdateEffect(fn, [inputValue]); - return setValue(2)} />; -}; - -const wrapper = mount(); - -describe('useDidUpdateEffect', () => { - it('should not fire function when value unchanged', () => { - expect(fn).not.toHaveBeenCalled(); - }); - - it('should fire function when value changed', () => { - wrapper.find(EuiLink).simulate('click'); - expect(fn).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.tsx deleted file mode 100644 index 4c3e10fc84b84..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.tsx +++ /dev/null @@ -1,23 +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. - */ - -/* - * Sometimes we don't want to fire the initial useEffect call. - * This custom Hook only fires after the intial render has completed. - */ -import { useEffect, useRef, DependencyList } from 'react'; - -export const useDidUpdateEffect = (fn: Function, inputs: DependencyList) => { - const didMountRef = useRef(false); - - useEffect(() => { - if (didMountRef.current) { - fn(); - } else { - didMountRef.current = true; - } - }, inputs); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts index 7789d0caba345..c305ae9d5f7a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts @@ -20,7 +20,7 @@ export const contentSources = [ boost: 1, }, { - id: '123', + id: '124', serviceType: 'jira', searchable: true, supportedByLicense: true, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/__mocks__/group_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/__mocks__/group_logic.mock.ts new file mode 100644 index 0000000000000..18e7851485222 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/__mocks__/group_logic.mock.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IGroupValues } from '../group_logic'; + +import { IGroupDetails, ISourcePriority } from '../../../types'; + +export const mockGroupValues = { + group: {} as IGroupDetails, + dataLoading: true, + manageUsersModalVisible: false, + managerModalFormErrors: [], + sharedSourcesModalVisible: false, + confirmDeleteModalVisible: false, + groupNameInputValue: '', + selectedGroupSources: [], + selectedGroupUsers: [], + groupPrioritiesUnchanged: true, + activeSourcePriorities: {} as ISourcePriority, + cachedSourcePriorities: {} as ISourcePriority, +} as IGroupValues; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/__mocks__/groups_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/__mocks__/groups_logic.mock.ts new file mode 100644 index 0000000000000..0483799b73093 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/__mocks__/groups_logic.mock.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IGroupsValues } from '../groups_logic'; + +import { IContentSource, IUser, IGroup } from '../../../types'; + +import { DEFAULT_META } from '../../../../shared/constants'; + +export const mockGroupsValues = { + groups: [] as IGroup[], + contentSources: [] as IContentSource[], + users: [] as IUser[], + groupsDataLoading: true, + groupListLoading: true, + newGroupModalOpen: false, + newGroupName: '', + hasFiltersSet: false, + newGroup: null, + newGroupNameErrors: [], + filterSourcesDropdownOpen: false, + filteredSources: [], + filterUsersDropdownOpen: false, + filteredUsers: [], + allGroupUsersLoading: false, + allGroupUsers: [], + filterValue: '', + groupsMeta: DEFAULT_META, +} as IGroupsValues; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts new file mode 100644 index 0000000000000..b4724c8ed3f0e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts @@ -0,0 +1,509 @@ +/* + * 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 { resetContext } from 'kea'; + +jest.mock('../../../shared/http', () => ({ + HttpLogic: { + values: { http: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() } }, + }, +})); +import { HttpLogic } from '../../../shared/http'; + +jest.mock('../../../shared/flash_messages', () => ({ + FlashMessagesLogic: { actions: { clearFlashMessages: jest.fn(), setQueuedMessages: jest.fn() } }, + flashAPIErrors: jest.fn(), + setSuccessMessage: jest.fn(), + setQueuedSuccessMessage: jest.fn(), +})); +import { + FlashMessagesLogic, + flashAPIErrors, + setSuccessMessage, + setQueuedSuccessMessage, +} from '../../../shared/flash_messages'; + +jest.mock('../../../shared/kibana', () => ({ + KibanaLogic: { values: { navigateToUrl: jest.fn() } }, +})); +import { KibanaLogic } from '../../../shared/kibana'; + +import { groups } from '../../__mocks__/groups.mock'; +import { mockGroupValues } from './__mocks__/group_logic.mock'; +import { GroupLogic } from './group_logic'; + +import { GROUPS_PATH } from '../../routes'; + +describe('GroupLogic', () => { + const group = groups[0]; + const sourceIds = ['123', '124']; + const userIds = ['1z1z']; + const sourcePriorities = { [sourceIds[0]]: 1, [sourceIds[1]]: 0.5 }; + const clearFlashMessagesSpy = jest.spyOn(FlashMessagesLogic.actions, 'clearFlashMessages'); + + beforeEach(() => { + jest.clearAllMocks(); + resetContext({}); + GroupLogic.mount(); + }); + + it('has expected default values', () => { + expect(GroupLogic.values).toEqual(mockGroupValues); + }); + + describe('actions', () => { + describe('onInitializeGroup', () => { + it('sets reducers', () => { + GroupLogic.actions.onInitializeGroup(group); + + expect(GroupLogic.values.group).toEqual(group); + expect(GroupLogic.values.dataLoading).toEqual(false); + expect(GroupLogic.values.groupNameInputValue).toEqual(group.name); + expect(GroupLogic.values.selectedGroupSources).toEqual(sourceIds); + expect(GroupLogic.values.selectedGroupUsers).toEqual(userIds); + expect(GroupLogic.values.cachedSourcePriorities).toEqual(sourcePriorities); + expect(GroupLogic.values.activeSourcePriorities).toEqual(sourcePriorities); + expect(GroupLogic.values.groupPrioritiesUnchanged).toEqual(true); + }); + }); + + describe('onGroupNameChanged', () => { + it('sets reducers', () => { + const renamedGroup = { + ...group, + name: 'changed', + }; + GroupLogic.actions.onGroupNameChanged(renamedGroup); + + expect(GroupLogic.values.group).toEqual(renamedGroup); + expect(GroupLogic.values.groupNameInputValue).toEqual(renamedGroup.name); + }); + }); + + describe('onGroupPrioritiesChanged', () => { + it('sets reducers', () => { + GroupLogic.actions.onGroupPrioritiesChanged(group); + + expect(GroupLogic.values.dataLoading).toEqual(false); + expect(GroupLogic.values.cachedSourcePriorities).toEqual(sourcePriorities); + expect(GroupLogic.values.activeSourcePriorities).toEqual(sourcePriorities); + }); + }); + + describe('onGroupNameInputChange', () => { + it('sets reducers', () => { + const name = 'new name'; + GroupLogic.actions.onGroupNameInputChange(name); + + expect(GroupLogic.values.groupNameInputValue).toEqual(name); + }); + }); + + describe('addGroupSource', () => { + it('sets reducer', () => { + GroupLogic.actions.addGroupSource(sourceIds[0]); + + expect(GroupLogic.values.selectedGroupSources).toEqual([sourceIds[0]]); + }); + }); + + describe('removeGroupSource', () => { + it('sets reducers', () => { + GroupLogic.actions.addGroupSource(sourceIds[0]); + GroupLogic.actions.addGroupSource(sourceIds[1]); + GroupLogic.actions.removeGroupSource(sourceIds[0]); + + expect(GroupLogic.values.selectedGroupSources).toEqual([sourceIds[1]]); + }); + }); + + describe('addGroupUser', () => { + it('sets reducer', () => { + GroupLogic.actions.addGroupUser(sourceIds[0]); + + expect(GroupLogic.values.selectedGroupUsers).toEqual([sourceIds[0]]); + }); + }); + + describe('removeGroupUser', () => { + it('sets reducers', () => { + GroupLogic.actions.addGroupUser(sourceIds[0]); + GroupLogic.actions.addGroupUser(sourceIds[1]); + GroupLogic.actions.removeGroupUser(sourceIds[0]); + + expect(GroupLogic.values.selectedGroupUsers).toEqual([sourceIds[1]]); + }); + }); + + describe('onGroupSourcesSaved', () => { + it('sets reducers', () => { + GroupLogic.actions.onGroupSourcesSaved(group); + + expect(GroupLogic.values.group).toEqual(group); + expect(GroupLogic.values.sharedSourcesModalVisible).toEqual(false); + expect(GroupLogic.values.selectedGroupSources).toEqual(sourceIds); + expect(GroupLogic.values.cachedSourcePriorities).toEqual(sourcePriorities); + expect(GroupLogic.values.activeSourcePriorities).toEqual(sourcePriorities); + }); + }); + + describe('onGroupUsersSaved', () => { + it('sets reducers', () => { + GroupLogic.actions.onGroupUsersSaved(group); + + expect(GroupLogic.values.group).toEqual(group); + expect(GroupLogic.values.manageUsersModalVisible).toEqual(false); + expect(GroupLogic.values.selectedGroupUsers).toEqual(userIds); + }); + }); + + describe('setGroupModalErrors', () => { + it('sets reducers', () => { + const errors = ['this is an error']; + GroupLogic.actions.setGroupModalErrors(errors); + + expect(GroupLogic.values.managerModalFormErrors).toEqual(errors); + }); + }); + + describe('hideSharedSourcesModal', () => { + it('sets reducers', () => { + GroupLogic.actions.hideSharedSourcesModal(group); + + expect(GroupLogic.values.sharedSourcesModalVisible).toEqual(false); + expect(GroupLogic.values.selectedGroupSources).toEqual(sourceIds); + }); + }); + + describe('hideManageUsersModal', () => { + it('sets reducers', () => { + GroupLogic.actions.hideManageUsersModal(group); + + expect(GroupLogic.values.manageUsersModalVisible).toEqual(false); + expect(GroupLogic.values.managerModalFormErrors).toEqual([]); + expect(GroupLogic.values.selectedGroupUsers).toEqual(userIds); + }); + }); + + describe('selectAllSources', () => { + it('sets reducers', () => { + GroupLogic.actions.selectAllSources(group.contentSources); + + expect(GroupLogic.values.selectedGroupSources).toEqual(sourceIds); + }); + }); + + describe('selectAllUsers', () => { + it('sets reducers', () => { + GroupLogic.actions.selectAllUsers(group.users); + + expect(GroupLogic.values.selectedGroupUsers).toEqual(userIds); + }); + }); + + describe('updatePriority', () => { + it('sets reducers', () => { + const PRIORITY_VALUE = 4; + GroupLogic.actions.updatePriority(sourceIds[0], PRIORITY_VALUE); + + expect(GroupLogic.values.activeSourcePriorities).toEqual({ + [sourceIds[0]]: PRIORITY_VALUE, + }); + expect(GroupLogic.values.groupPrioritiesUnchanged).toEqual(false); + }); + }); + + describe('resetGroup', () => { + it('sets reducers', () => { + GroupLogic.actions.resetGroup(); + + expect(GroupLogic.values.group).toEqual({}); + expect(GroupLogic.values.dataLoading).toEqual(true); + }); + }); + + describe('hideConfirmDeleteModal', () => { + it('sets reducer', () => { + GroupLogic.actions.showConfirmDeleteModal(); + GroupLogic.actions.hideConfirmDeleteModal(); + + expect(GroupLogic.values.confirmDeleteModalVisible).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('initializeGroup', () => { + it('calls API and sets values', async () => { + const onInitializeGroupSpy = jest.spyOn(GroupLogic.actions, 'onInitializeGroup'); + const promise = Promise.resolve(group); + (HttpLogic.values.http.get as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.initializeGroup(sourceIds[0]); + expect(HttpLogic.values.http.get).toHaveBeenCalledWith('/api/workplace_search/groups/123'); + await promise; + expect(onInitializeGroupSpy).toHaveBeenCalledWith(group); + }); + + it('handles 404 error', async () => { + const promise = Promise.reject({ response: { status: 404 } }); + (HttpLogic.values.http.get as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.initializeGroup(sourceIds[0]); + + try { + await promise; + } catch { + expect(KibanaLogic.values.navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); + expect(FlashMessagesLogic.actions.setQueuedMessages).toHaveBeenCalledWith({ + type: 'error', + message: 'Unable to find group with ID: "123".', + }); + } + }); + + it('handles non-404 error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.get as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.initializeGroup(sourceIds[0]); + + try { + await promise; + } catch { + expect(KibanaLogic.values.navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); + expect(FlashMessagesLogic.actions.setQueuedMessages).toHaveBeenCalledWith({ + type: 'error', + message: 'this is an error', + }); + } + }); + }); + + describe('deleteGroup', () => { + beforeEach(() => { + GroupLogic.actions.onInitializeGroup(group); + }); + it('deletes a group', async () => { + const promise = Promise.resolve(true); + (HttpLogic.values.http.delete as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.deleteGroup(); + expect(HttpLogic.values.http.delete).toHaveBeenCalledWith( + '/api/workplace_search/groups/123' + ); + + await promise; + expect(KibanaLogic.values.navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); + expect(setQueuedSuccessMessage).toHaveBeenCalledWith( + 'Group "group" was successfully deleted.' + ); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.delete as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.deleteGroup(); + try { + await promise; + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('updateGroupName', () => { + beforeEach(() => { + GroupLogic.actions.onInitializeGroup(group); + GroupLogic.actions.onGroupNameInputChange('new name'); + }); + it('updates name', async () => { + const onGroupNameChangedSpy = jest.spyOn(GroupLogic.actions, 'onGroupNameChanged'); + const promise = Promise.resolve(group); + (HttpLogic.values.http.put as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.updateGroupName(); + expect(HttpLogic.values.http.put).toHaveBeenCalledWith('/api/workplace_search/groups/123', { + body: JSON.stringify({ group: { name: 'new name' } }), + }); + + await promise; + expect(onGroupNameChangedSpy).toHaveBeenCalledWith(group); + expect(setSuccessMessage).toHaveBeenCalledWith( + 'Successfully renamed this group to "group".' + ); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.put as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.updateGroupName(); + try { + await promise; + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('saveGroupSources', () => { + beforeEach(() => { + GroupLogic.actions.onInitializeGroup(group); + GroupLogic.actions.selectAllSources(group.contentSources); + }); + it('updates name', async () => { + const onGroupSourcesSavedSpy = jest.spyOn(GroupLogic.actions, 'onGroupSourcesSaved'); + const promise = Promise.resolve(group); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.saveGroupSources(); + expect(HttpLogic.values.http.post).toHaveBeenCalledWith( + '/api/workplace_search/groups/123/share', + { + body: JSON.stringify({ content_source_ids: sourceIds }), + } + ); + + await promise; + expect(onGroupSourcesSavedSpy).toHaveBeenCalledWith(group); + expect(setSuccessMessage).toHaveBeenCalledWith( + 'Successfully updated shared content sources.' + ); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.saveGroupSources(); + try { + await promise; + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('saveGroupUsers', () => { + beforeEach(() => { + GroupLogic.actions.onInitializeGroup(group); + }); + it('updates name', async () => { + const onGroupUsersSavedSpy = jest.spyOn(GroupLogic.actions, 'onGroupUsersSaved'); + const promise = Promise.resolve(group); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.saveGroupUsers(); + expect(HttpLogic.values.http.post).toHaveBeenCalledWith( + '/api/workplace_search/groups/123/assign', + { + body: JSON.stringify({ user_ids: userIds }), + } + ); + + await promise; + expect(onGroupUsersSavedSpy).toHaveBeenCalledWith(group); + expect(setSuccessMessage).toHaveBeenCalledWith( + 'Successfully updated the users of this group.' + ); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.saveGroupUsers(); + try { + await promise; + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('saveGroupSourcePrioritization', () => { + beforeEach(() => { + GroupLogic.actions.onInitializeGroup(group); + }); + it('updates name', async () => { + const onGroupPrioritiesChangedSpy = jest.spyOn( + GroupLogic.actions, + 'onGroupPrioritiesChanged' + ); + const promise = Promise.resolve(group); + (HttpLogic.values.http.put as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.saveGroupSourcePrioritization(); + expect(HttpLogic.values.http.put).toHaveBeenCalledWith( + '/api/workplace_search/groups/123/boosts', + { + body: JSON.stringify({ + content_source_boosts: [ + [sourceIds[0], 1], + [sourceIds[1], 0.5], + ], + }), + } + ); + + await promise; + expect(setSuccessMessage).toHaveBeenCalledWith( + 'Successfully updated shared source prioritization.' + ); + expect(onGroupPrioritiesChangedSpy).toHaveBeenCalledWith(group); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.put as jest.Mock).mockReturnValue(promise); + + GroupLogic.actions.saveGroupSourcePrioritization(); + try { + await promise; + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('showConfirmDeleteModal', () => { + it('sets reducer and clears flash messages', () => { + GroupLogic.actions.showConfirmDeleteModal(); + + expect(GroupLogic.values.confirmDeleteModalVisible).toEqual(true); + expect(clearFlashMessagesSpy).toHaveBeenCalled(); + }); + }); + + describe('showSharedSourcesModal', () => { + it('sets reducer and clears flash messages', () => { + GroupLogic.actions.showSharedSourcesModal(); + + expect(GroupLogic.values.sharedSourcesModalVisible).toEqual(true); + expect(clearFlashMessagesSpy).toHaveBeenCalled(); + }); + }); + + describe('showManageUsersModal', () => { + it('sets reducer and clears flash messages', () => { + GroupLogic.actions.showManageUsersModal(); + + expect(GroupLogic.values.manageUsersModalVisible).toEqual(true); + expect(clearFlashMessagesSpy).toHaveBeenCalled(); + }); + }); + + describe('resetFlashMessages', () => { + it('clears flash messages', () => { + GroupLogic.actions.resetFlashMessages(); + + expect(clearFlashMessagesSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts index 1ce0fe53726d4..b895200d3fc22 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts @@ -56,13 +56,11 @@ export interface IGroupActions { } export interface IGroupValues { - contentSources: IContentSourceDetails[]; - users: IUser[]; group: IGroupDetails; dataLoading: boolean; manageUsersModalVisible: boolean; managerModalFormErrors: string[]; - sharedSourcesModalModalVisible: boolean; + sharedSourcesModalVisible: boolean; confirmDeleteModalVisible: boolean; groupNameInputValue: string; selectedGroupSources: string[]; @@ -138,7 +136,7 @@ export const GroupLogic = kea>({ hideManageUsersModal: () => [], }, ], - sharedSourcesModalModalVisible: [ + sharedSourcesModalVisible: [ false, { showSharedSourcesModal: () => true, @@ -225,8 +223,7 @@ export const GroupLogic = kea>({ } ); - const error = e.response.status === 404 ? NOT_FOUND_MESSAGE : e; - + const error = e.response?.status === 404 ? NOT_FOUND_MESSAGE : e; FlashMessagesLogic.actions.setQueuedMessages({ type: 'error', message: error, @@ -321,7 +318,7 @@ export const GroupLogic = kea>({ const GROUP_USERS_UPDATED_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.groupUsersUpdated', { - defaultMessage: 'Successfully updated the users of this group', + defaultMessage: 'Successfully updated the users of this group.', } ); setSuccessMessage(GROUP_USERS_UPDATED_MESSAGE); @@ -353,7 +350,7 @@ export const GroupLogic = kea>({ const GROUP_PRIORITIZATION_UPDATED_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.groupPrioritizationUpdated', { - defaultMessage: 'Successfully updated shared source prioritization', + defaultMessage: 'Successfully updated shared source prioritization.', } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.test.tsx new file mode 100644 index 0000000000000..6f293920fa387 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/kea.mock'; +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Route, Switch } from 'react-router-dom'; + +import { groups } from '../../__mocks__/groups.mock'; + +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; + +import { GroupOverview } from './components/group_overview'; +import { GroupSourcePrioritization } from './components/group_source_prioritization'; + +import { GroupRouter } from './group_router'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { ManageUsersModal } from './components/manage_users_modal'; +import { SharedSourcesModal } from './components/shared_sources_modal'; + +describe('GroupRouter', () => { + const initializeGroup = jest.fn(); + const resetGroup = jest.fn(); + + beforeEach(() => { + setMockValues({ + sharedSourcesModalVisible: false, + manageUsersModalVisible: false, + group: groups[0], + }); + + setMockActions({ + initializeGroup, + resetGroup, + }); + }); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(FlashMessages)).toHaveLength(1); + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(2); + expect(wrapper.find(GroupOverview)).toHaveLength(1); + expect(wrapper.find(GroupSourcePrioritization)).toHaveLength(1); + }); + + it('renders modals', () => { + setMockValues({ + sharedSourcesModalVisible: true, + manageUsersModalVisible: true, + group: groups[0], + }); + + const wrapper = shallow(); + + expect(wrapper.find(ManageUsersModal)).toHaveLength(1); + expect(wrapper.find(SharedSourcesModal)).toHaveLength(1); + }); + + it('handles breadcrumbs while loading', () => { + setMockValues({ + sharedSourcesModalVisible: false, + manageUsersModalVisible: false, + group: {}, + }); + + const loadingBreadcrumbs = ['Groups', '...']; + + const wrapper = shallow(); + + const firstBreadCrumb = wrapper.find(SetPageChrome).first(); + const lastBreadCrumb = wrapper.find(SetPageChrome).last(); + + expect(firstBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, 'Source Prioritization']); + expect(lastBreadCrumb.prop('trail')).toEqual(loadingBreadcrumbs); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx index 0a637497a5b05..1b6f0c4c49a05 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { Route, Switch, useParams } from 'react-router-dom'; -import { FlashMessages, FlashMessagesLogic } from '../../../shared/flash_messages'; +import { FlashMessages } from '../../../shared/flash_messages'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; @@ -26,16 +26,13 @@ import { GroupSourcePrioritization } from './components/group_source_prioritizat export const GroupRouter: React.FC = () => { const { groupId } = useParams() as { groupId: string }; - const { messages } = useValues(FlashMessagesLogic); const { initializeGroup, resetGroup } = useActions(GroupLogic); const { - sharedSourcesModalModalVisible, + sharedSourcesModalVisible, manageUsersModalVisible, group: { name }, } = useValues(GroupLogic); - const hasMessages = messages.length > 0; - useEffect(() => { initializeGroup(groupId); return resetGroup; @@ -43,7 +40,7 @@ export const GroupRouter: React.FC = () => { return ( <> - {hasMessages && } + @@ -55,7 +52,7 @@ export const GroupRouter: React.FC = () => { - {sharedSourcesModalModalVisible && } + {sharedSourcesModalVisible && } {manageUsersModalVisible && } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.test.tsx new file mode 100644 index 0000000000000..98f40acb96469 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.test.tsx @@ -0,0 +1,180 @@ +/* + * 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 '../../../__mocks__/kea.mock'; +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockActions, setMockValues } from '../../../__mocks__'; +import { groups } from '../../__mocks__/groups.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Groups } from './groups'; + +import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { Loading } from '../../components/shared/loading'; +import { FlashMessages } from '../../../shared/flash_messages'; + +import { AddGroupModal } from './components/add_group_modal'; +import { ClearFiltersLink } from './components/clear_filters_link'; +import { GroupsTable } from './components/groups_table'; +import { TableFilters } from './components/table_filters'; + +import { DEFAULT_META } from '../../../shared/constants'; + +import { EuiFieldSearch, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiButton as EuiLinkButton } from '../../../shared/react_router_helpers'; + +const getSearchResults = jest.fn(); +const openNewGroupModal = jest.fn(); +const resetGroups = jest.fn(); +const setFilterValue = jest.fn(); +const setActivePage = jest.fn(); + +const mockMeta = { + ...DEFAULT_META, + page: { + current: 1, + total_results: 50, + total_pages: 5, + }, +}; + +const mockSuccessMessage = { + type: 'success', + message: 'group added', +}; + +const mockValues = { + groups, + groupsDataLoading: false, + newGroupModalOpen: false, + newGroup: null, + groupListLoading: false, + hasFiltersSet: false, + groupsMeta: mockMeta, + filteredSources: [], + filteredUsers: [], + filterValue: '', + isFederatedAuth: false, +}; + +describe('GroupOverview', () => { + beforeEach(() => { + setMockActions({ + getSearchResults, + openNewGroupModal, + resetGroups, + setFilterValue, + setActivePage, + }); + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(GroupsTable)).toHaveLength(1); + expect(wrapper.find(TableFilters)).toHaveLength(1); + }); + + it('returns loading when loading', () => { + setMockValues({ ...mockValues, groupsDataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('gets search results when filters changed', () => { + const wrapper = shallow(); + + const filters = wrapper.find(TableFilters).dive().shallow(); + const input = filters.find(EuiFieldSearch); + + input.simulate('change', { target: { value: 'Query' } }); + + expect(getSearchResults).toHaveBeenCalledWith(true); + }); + + it('renders manage button when new group added', () => { + setMockValues({ + ...mockValues, + newGroup: { name: 'group', id: '123' }, + messages: [mockSuccessMessage], + }); + const wrapper = shallow(); + const flashMessages = wrapper.find(FlashMessages).dive().shallow(); + + expect(flashMessages.find('[data-test-subj="NewGroupManageButton"]')).toHaveLength(1); + }); + + it('renders ClearFiltersLink when filters set', () => { + setMockValues({ + ...mockValues, + hasFiltersSet: true, + groupsMeta: DEFAULT_META, + }); + + const wrapper = shallow(); + + expect(wrapper.find(ClearFiltersLink)).toHaveLength(1); + }); + + it('renders inviteUsersButton when not federated auth', () => { + setMockValues({ + ...mockValues, + isFederatedAuth: false, + }); + + const wrapper = shallow(); + + const Action: React.FC = () => + wrapper.find(ViewContentHeader).props().action as React.ReactElement | null; + const action = shallow(); + + expect(action.find('[data-test-subj="InviteUsersButton"]')).toHaveLength(1); + expect(action.find(EuiLinkButton)).toHaveLength(1); + }); + + it('does not render inviteUsersButton when federated auth', () => { + setMockValues({ + ...mockValues, + isFederatedAuth: true, + }); + + const wrapper = shallow(); + + const Action: React.FC = () => + wrapper.find(ViewContentHeader).props().action as React.ReactElement | null; + const action = shallow(); + + expect(action.find('[data-test-subj="InviteUsersButton"]')).toHaveLength(0); + }); + + it('renders EuiLoadingSpinner when loading', () => { + setMockValues({ + ...mockValues, + groupListLoading: true, + }); + + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + }); + + it('renders AddGroupModal', () => { + setMockValues({ + ...mockValues, + newGroupModalOpen: true, + }); + + const wrapper = shallow(); + + expect(wrapper.find(AddGroupModal)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx index 34a66282a312d..ae87ef735bb9f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx @@ -19,7 +19,6 @@ import { ViewContentHeader } from '../../components/shared/view_content_header'; import { getGroupPath, USERS_PATH } from '../../routes'; -import { useDidUpdateEffect } from '../../../shared/use_did_update_effect'; import { FlashMessages, FlashMessagesLogic } from '../../../shared/flash_messages'; import { GroupsLogic } from './groups_logic'; @@ -40,7 +39,7 @@ export const Groups: React.FC = () => { groupListLoading, hasFiltersSet, groupsMeta: { - page: { current: activePage, total_results: numGroups }, + page: { total_results: numGroups }, }, filteredSources, filteredUsers, @@ -56,18 +55,17 @@ export const Groups: React.FC = () => { return resetGroups; }, [filteredSources, filteredUsers, filterValue]); - // Because the initial search happens above, we want to skip the initial search and use the custom hook to do so. - useDidUpdateEffect(() => { - getSearchResults(); - }, [activePage]); - if (groupsDataLoading) { return ; } if (newGroup && hasMessages) { messages[0].description = ( - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.newGroup.action', { defaultMessage: 'Manage Group', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts new file mode 100644 index 0000000000000..18206573a3978 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts @@ -0,0 +1,432 @@ +/* + * 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 { resetContext } from 'kea'; + +jest.mock('../../../shared/http', () => ({ + HttpLogic: { + values: { http: { get: jest.fn(), post: jest.fn() } }, + }, +})); +import { HttpLogic } from '../../../shared/http'; + +jest.mock('../../../shared/flash_messages', () => ({ + FlashMessagesLogic: { actions: { clearFlashMessages: jest.fn(), setQueuedMessages: jest.fn() } }, + flashAPIErrors: jest.fn(), + setSuccessMessage: jest.fn(), + setQueuedSuccessMessage: jest.fn(), +})); +import { FlashMessagesLogic, flashAPIErrors } from '../../../shared/flash_messages'; + +import { DEFAULT_META } from '../../../shared/constants'; +import { JSON_HEADER as headers } from '../../../../../common/constants'; + +import { groups } from '../../__mocks__/groups.mock'; +import { contentSources } from '../../__mocks__/content_sources.mock'; +import { users } from '../../__mocks__/users.mock'; +import { mockGroupsValues } from './__mocks__/groups_logic.mock'; +import { GroupsLogic } from './groups_logic'; + +// We need to mock out the debounced functionality +const TIMEOUT = 400; +const delay = () => new Promise((resolve) => setTimeout(resolve, TIMEOUT)); + +describe('GroupsLogic', () => { + const clearFlashMessagesSpy = jest.spyOn(FlashMessagesLogic.actions, 'clearFlashMessages'); + const groupsResponse = { + results: groups, + meta: DEFAULT_META, + }; + + beforeEach(() => { + jest.clearAllMocks(); + resetContext({}); + GroupsLogic.mount(); + }); + + it('has expected default values', () => { + expect(GroupsLogic.values).toEqual(mockGroupsValues); + }); + + describe('actions', () => { + describe('onInitializeGroups', () => { + it('sets reducers', () => { + GroupsLogic.actions.onInitializeGroups({ contentSources, users }); + + expect(GroupsLogic.values.groupsDataLoading).toEqual(false); + expect(GroupsLogic.values.contentSources).toEqual(contentSources); + expect(GroupsLogic.values.users).toEqual(users); + }); + }); + + describe('setSearchResults', () => { + it('sets reducers', () => { + GroupsLogic.actions.setSearchResults(groupsResponse); + + expect(GroupsLogic.values.groups).toEqual(groups); + expect(GroupsLogic.values.groupListLoading).toEqual(false); + expect(GroupsLogic.values.newGroupName).toEqual(''); + expect(GroupsLogic.values.groupsMeta).toEqual(DEFAULT_META); + }); + }); + + describe('addFilteredSource', () => { + it('sets reducers', () => { + GroupsLogic.actions.addFilteredSource('foo'); + GroupsLogic.actions.addFilteredSource('bar'); + GroupsLogic.actions.addFilteredSource('baz'); + + expect(GroupsLogic.values.filteredSources).toEqual(['bar', 'baz', 'foo']); + }); + }); + + describe('removeFilteredSource', () => { + it('sets reducers', () => { + GroupsLogic.actions.addFilteredSource('foo'); + GroupsLogic.actions.addFilteredSource('bar'); + GroupsLogic.actions.addFilteredSource('baz'); + GroupsLogic.actions.removeFilteredSource('foo'); + + expect(GroupsLogic.values.filteredSources).toEqual(['bar', 'baz']); + }); + }); + + describe('addFilteredUser', () => { + it('sets reducers', () => { + GroupsLogic.actions.addFilteredUser('foo'); + GroupsLogic.actions.addFilteredUser('bar'); + GroupsLogic.actions.addFilteredUser('baz'); + + expect(GroupsLogic.values.filteredUsers).toEqual(['bar', 'baz', 'foo']); + }); + }); + + describe('removeFilteredUser', () => { + it('sets reducers', () => { + GroupsLogic.actions.addFilteredUser('foo'); + GroupsLogic.actions.addFilteredUser('bar'); + GroupsLogic.actions.addFilteredUser('baz'); + GroupsLogic.actions.removeFilteredUser('foo'); + + expect(GroupsLogic.values.filteredUsers).toEqual(['bar', 'baz']); + }); + }); + + describe('setGroupUsers', () => { + it('sets reducers', () => { + GroupsLogic.actions.setGroupUsers(users); + + expect(GroupsLogic.values.allGroupUsersLoading).toEqual(false); + expect(GroupsLogic.values.allGroupUsers).toEqual(users); + }); + }); + + describe('setAllGroupLoading', () => { + it('sets reducer', () => { + GroupsLogic.actions.setAllGroupLoading(true); + + expect(GroupsLogic.values.allGroupUsersLoading).toEqual(true); + expect(GroupsLogic.values.allGroupUsers).toEqual([]); + }); + }); + + describe('setFilterValue', () => { + it('sets reducer', () => { + GroupsLogic.actions.setFilterValue('foo'); + + expect(GroupsLogic.values.filterValue).toEqual('foo'); + }); + }); + + describe('setNewGroupName', () => { + it('sets reducer', () => { + const NEW_NAME = 'new name'; + GroupsLogic.actions.setNewGroupName(NEW_NAME); + + expect(GroupsLogic.values.newGroupName).toEqual(NEW_NAME); + expect(GroupsLogic.values.newGroupNameErrors).toEqual([]); + }); + }); + + describe('setNewGroup', () => { + it('sets reducer', () => { + GroupsLogic.actions.setNewGroup(groups[0]); + + expect(GroupsLogic.values.newGroupModalOpen).toEqual(false); + expect(GroupsLogic.values.newGroup).toEqual(groups[0]); + expect(GroupsLogic.values.newGroupNameErrors).toEqual([]); + expect(GroupsLogic.values.filteredSources).toEqual([]); + expect(GroupsLogic.values.filteredUsers).toEqual([]); + expect(GroupsLogic.values.groupsMeta).toEqual(DEFAULT_META); + }); + }); + + describe('setNewGroupFormErrors', () => { + it('sets reducer', () => { + const errors = ['this is an error']; + GroupsLogic.actions.setNewGroupFormErrors(errors); + + expect(GroupsLogic.values.newGroupNameErrors).toEqual(errors); + }); + }); + + describe('closeNewGroupModal', () => { + it('sets reducer', () => { + GroupsLogic.actions.closeNewGroupModal(); + + expect(GroupsLogic.values.newGroupModalOpen).toEqual(false); + expect(GroupsLogic.values.newGroupName).toEqual(''); + expect(GroupsLogic.values.newGroupNameErrors).toEqual([]); + }); + }); + + describe('closeFilterSourcesDropdown', () => { + it('sets reducer', () => { + // Open dropdown first + GroupsLogic.actions.toggleFilterSourcesDropdown(); + GroupsLogic.actions.closeFilterSourcesDropdown(); + + expect(GroupsLogic.values.filterSourcesDropdownOpen).toEqual(false); + }); + }); + + describe('closeFilterUsersDropdown', () => { + it('sets reducer', () => { + // Open dropdown first + GroupsLogic.actions.toggleFilterUsersDropdown(); + GroupsLogic.actions.closeFilterUsersDropdown(); + + expect(GroupsLogic.values.filterUsersDropdownOpen).toEqual(false); + }); + }); + + describe('setGroupsLoading', () => { + it('sets reducer', () => { + // Set to false first + GroupsLogic.actions.setSearchResults(groupsResponse); + GroupsLogic.actions.setGroupsLoading(); + + expect(GroupsLogic.values.groupListLoading).toEqual(true); + }); + }); + + describe('resetGroups', () => { + it('sets reducer', () => { + GroupsLogic.actions.resetGroups(); + + expect(GroupsLogic.values.newGroup).toEqual(null); + }); + }); + }); + + describe('listeners', () => { + describe('initializeGroups', () => { + it('calls API and sets values', async () => { + const onInitializeGroupsSpy = jest.spyOn(GroupsLogic.actions, 'onInitializeGroups'); + const promise = Promise.resolve(groupsResponse); + (HttpLogic.values.http.get as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.initializeGroups(); + expect(HttpLogic.values.http.get).toHaveBeenCalledWith('/api/workplace_search/groups'); + await promise; + expect(onInitializeGroupsSpy).toHaveBeenCalledWith(groupsResponse); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.get as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.initializeGroups(); + try { + await promise; + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('getSearchResults', () => { + const search = { + query: '', + content_source_ids: [], + user_ids: [], + }; + + const payload = { + body: JSON.stringify({ + page: { + current: 1, + size: 10, + }, + search, + }), + headers, + }; + + it('calls API and sets values', async () => { + const setSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'setSearchResults'); + const promise = Promise.resolve(groups); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.getSearchResults(); + await delay(); + expect(HttpLogic.values.http.post).toHaveBeenCalledWith( + '/api/workplace_search/groups/search', + payload + ); + await promise; + expect(setSearchResultsSpy).toHaveBeenCalledWith(groups); + }); + + it('calls API and resets pagination', async () => { + // Set active page to 2 to confirm resetting sends the `payload` value of 1 for the current page. + GroupsLogic.actions.setActivePage(2); + const setSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'setSearchResults'); + const promise = Promise.resolve(groups); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.getSearchResults(true); + // Account for `breakpoint` that debounces filter value. + await delay(); + expect(HttpLogic.values.http.post).toHaveBeenCalledWith( + '/api/workplace_search/groups/search', + payload + ); + await promise; + expect(setSearchResultsSpy).toHaveBeenCalledWith(groups); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.getSearchResults(); + try { + await promise; + } catch { + // Account for `breakpoint` that debounces filter value. + await delay(); + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('fetchGroupUsers', () => { + it('calls API and sets values', async () => { + const setGroupUsersSpy = jest.spyOn(GroupsLogic.actions, 'setGroupUsers'); + const promise = Promise.resolve(users); + (HttpLogic.values.http.get as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.fetchGroupUsers('123'); + expect(HttpLogic.values.http.get).toHaveBeenCalledWith( + '/api/workplace_search/groups/123/group_users' + ); + await promise; + expect(setGroupUsersSpy).toHaveBeenCalledWith(users); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.get as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.fetchGroupUsers('123'); + try { + await promise; + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('saveNewGroup', () => { + it('calls API and sets values', async () => { + const GROUP_NAME = 'new group'; + GroupsLogic.actions.setNewGroupName(GROUP_NAME); + const setNewGroupSpy = jest.spyOn(GroupsLogic.actions, 'setNewGroup'); + const promise = Promise.resolve(groups[0]); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.saveNewGroup(); + expect(HttpLogic.values.http.post).toHaveBeenCalledWith('/api/workplace_search/groups', { + body: JSON.stringify({ group_name: GROUP_NAME }), + headers, + }); + await promise; + expect(setNewGroupSpy).toHaveBeenCalledWith(groups[0]); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + (HttpLogic.values.http.post as jest.Mock).mockReturnValue(promise); + + GroupsLogic.actions.saveNewGroup(); + try { + await promise; + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('setActivePage', () => { + it('sets reducer', () => { + const getSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'getSearchResults'); + const activePage = 3; + GroupsLogic.actions.setActivePage(activePage); + + expect(GroupsLogic.values.groupsMeta).toEqual({ + ...DEFAULT_META, + page: { + ...DEFAULT_META.page, + current: activePage, + }, + }); + + expect(getSearchResultsSpy).toHaveBeenCalled(); + }); + }); + + describe('openNewGroupModal', () => { + it('sets reducer and clears flash messages', () => { + GroupsLogic.actions.openNewGroupModal(); + + expect(GroupsLogic.values.newGroupModalOpen).toEqual(true); + expect(GroupsLogic.values.newGroup).toEqual(null); + expect(clearFlashMessagesSpy).toHaveBeenCalled(); + }); + }); + + describe('resetGroupsFilters', () => { + it('sets reducer and clears flash messages', () => { + GroupsLogic.actions.resetGroupsFilters(); + + expect(GroupsLogic.values.filteredSources).toEqual([]); + expect(GroupsLogic.values.filteredUsers).toEqual([]); + expect(GroupsLogic.values.filterValue).toEqual(''); + expect(GroupsLogic.values.groupsMeta).toEqual(DEFAULT_META); + expect(clearFlashMessagesSpy).toHaveBeenCalled(); + }); + }); + + describe('toggleFilterSourcesDropdown', () => { + it('sets reducer and clears flash messages', () => { + GroupsLogic.actions.toggleFilterSourcesDropdown(); + + expect(GroupsLogic.values.filterSourcesDropdownOpen).toEqual(true); + expect(clearFlashMessagesSpy).toHaveBeenCalled(); + }); + }); + + describe('toggleFilterUsersDropdown', () => { + it('sets reducer and clears flash messages', () => { + GroupsLogic.actions.toggleFilterUsersDropdown(); + + expect(GroupsLogic.values.filterUsersDropdownOpen).toEqual(true); + expect(clearFlashMessagesSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts index 35d4387b4cf3d..685a2651cb24a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts @@ -335,6 +335,9 @@ export const GroupsLogic = kea>({ flashAPIErrors(e); } }, + setActivePage: () => { + actions.getSearchResults(); + }, openNewGroupModal: () => { FlashMessagesLogic.actions.clearFlashMessages(); }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.test.tsx new file mode 100644 index 0000000000000..0b2b1ad05dfd7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/kea.mock'; +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockActions } from '../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Route, Switch } from 'react-router-dom'; + +import { GroupsRouter } from './groups_router'; + +import { GroupRouter } from './group_router'; +import { Groups } from './groups'; + +describe('GroupsRouter', () => { + const initializeGroups = jest.fn(); + + beforeEach(() => { + setMockActions({ initializeGroups }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(2); + expect(wrapper.find(GroupRouter)).toHaveLength(1); + expect(wrapper.find(Groups)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx index adfa10d37c524..a4fe472065d90 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx @@ -37,7 +37,9 @@ export const GroupsRouter: React.FC = () => { - + + + ); }; diff --git a/x-pack/plugins/infra/server/routes/metadata/index.ts b/x-pack/plugins/infra/server/routes/metadata/index.ts index b2664d5a6d9fe..ee71167b5a1c7 100644 --- a/x-pack/plugins/infra/server/routes/metadata/index.ts +++ b/x-pack/plugins/infra/server/routes/metadata/index.ts @@ -58,7 +58,14 @@ export const initMetadataRoute = (libs: InfraBackendLibs) => { nameToFeature('metrics') ); - const info = await getNodeInfo(framework, requestContext, configuration, nodeId, nodeType); + const info = await getNodeInfo( + framework, + requestContext, + configuration, + nodeId, + nodeType, + timeRange + ); const cloudInstanceId = get(info, 'cloud.instance.id'); const cloudMetricsMetadata = cloudInstanceId diff --git a/x-pack/plugins/infra/server/routes/metadata/lib/get_node_info.ts b/x-pack/plugins/infra/server/routes/metadata/lib/get_node_info.ts index d0f0bd18b5d56..f1341c7ec8101 100644 --- a/x-pack/plugins/infra/server/routes/metadata/lib/get_node_info.ts +++ b/x-pack/plugins/infra/server/routes/metadata/lib/get_node_info.ts @@ -20,7 +20,8 @@ export const getNodeInfo = async ( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, nodeId: string, - nodeType: InventoryItemType + nodeType: InventoryItemType, + timeRange: { from: number; to: number } ): Promise => { // If the nodeType is a Kubernetes pod then we need to get the node info // from a host record instead of a pod. This is due to the fact that any host @@ -33,7 +34,8 @@ export const getNodeInfo = async ( requestContext, sourceConfiguration, nodeId, - nodeType + nodeType, + timeRange ); if (kubernetesNodeName) { return getNodeInfo( @@ -41,12 +43,14 @@ export const getNodeInfo = async ( requestContext, sourceConfiguration, kubernetesNodeName, - 'host' + 'host', + timeRange ); } return {}; } const fields = findInventoryFields(nodeType, sourceConfiguration.fields); + const timestampField = sourceConfiguration.fields.timestamp; const params = { allowNoIndices: true, ignoreUnavailable: true, @@ -55,9 +59,21 @@ export const getNodeInfo = async ( body: { size: 1, _source: ['host.*', 'cloud.*'], + sort: [{ [timestampField]: 'desc' }], query: { bool: { - filter: [{ match: { [fields.id]: nodeId } }], + filter: [ + { match: { [fields.id]: nodeId } }, + { + range: { + [timestampField]: { + gte: timeRange.from, + lte: timeRange.to, + format: 'epoch_millis', + }, + }, + }, + ], }, }, }, diff --git a/x-pack/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts b/x-pack/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts index be6e29a794d09..b4656178d395e 100644 --- a/x-pack/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts +++ b/x-pack/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts @@ -15,9 +15,11 @@ export const getPodNodeName = async ( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, nodeId: string, - nodeType: 'host' | 'pod' | 'container' + nodeType: 'host' | 'pod' | 'container', + timeRange: { from: number; to: number } ): Promise => { const fields = findInventoryFields(nodeType, sourceConfiguration.fields); + const timestampField = sourceConfiguration.fields.timestamp; const params = { allowNoIndices: true, ignoreUnavailable: true, @@ -26,11 +28,21 @@ export const getPodNodeName = async ( body: { size: 1, _source: ['kubernetes.node.name'], + sort: [{ [timestampField]: 'desc' }], query: { bool: { filter: [ { match: { [fields.id]: nodeId } }, { exists: { field: `kubernetes.node.name` } }, + { + range: { + [timestampField]: { + gte: timeRange.from, + lte: timeRange.to, + format: 'epoch_millis', + }, + }, + }, ], }, }, diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_policy.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_policy.ts index aa9fbc20fc0b0..5fe72fd57f3ed 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_policy.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_policy.ts @@ -63,6 +63,7 @@ export interface DeleteAgentPolicyRequest { export interface DeleteAgentPolicyResponse { id: string; + name: string; } export interface GetFullAgentPolicyRequest { diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/package_policy.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/package_policy.ts index 61669ab876d80..171902471e178 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/package_policy.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/package_policy.ts @@ -52,5 +52,6 @@ export interface DeletePackagePoliciesRequest { export type DeletePackagePoliciesResponse = Array<{ id: string; + name?: string; success: boolean; }>; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/agent_policy_delete_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/agent_policy_delete_provider.tsx index 0266b4a90abe4..ab8a9c99227c5 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/agent_policy_delete_provider.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/agent_policy_delete_provider.tsx @@ -63,7 +63,7 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent = ({ chil notifications.toasts.addSuccess( i18n.translate('xpack.ingestManager.deleteAgentPolicy.successSingleNotificationTitle', { defaultMessage: "Deleted agent policy '{id}'", - values: { id: agentPolicy }, + values: { id: data.name || data.id }, }) ); if (onSuccessCallback.current) { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/package_policy_delete_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/package_policy_delete_provider.tsx index 9242c6eb86225..ff5961b94f726 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/package_policy_delete_provider.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/package_policy_delete_provider.tsx @@ -106,7 +106,7 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent = ({ 'xpack.ingestManager.deletePackagePolicy.successSingleNotificationTitle', { defaultMessage: "Deleted integration '{id}'", - values: { id: successfulResults[0].id }, + values: { id: successfulResults[0].name || successfulResults[0].id }, } ); notifications.toasts.addSuccess(successMessage); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 83cbb9ccb728c..bc37338f04394 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -173,6 +173,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Agent data states const [showInactive, setShowInactive] = useState(false); const [showUpgradeable, setShowUpgradeable] = useState(false); + // Table and search states const [search, setSearch] = useState(defaultKuery); const [selectionMode, setSelectionMode] = useState('manual'); @@ -188,11 +189,20 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState(false); const [selectedStatus, setSelectedStatus] = useState([]); + const isUsingFilter = + search.trim() || + selectedAgentPolicies.length || + selectedStatus.length || + showInactive || + showUpgradeable; + const clearFilters = useCallback(() => { setSearch(''); setSelectedAgentPolicies([]); setSelectedStatus([]); - }, [setSearch, setSelectedAgentPolicies, setSelectedStatus]); + setShowInactive(false); + setShowUpgradeable(false); + }, [setSearch, setSelectedAgentPolicies, setSelectedStatus, setShowInactive, setShowUpgradeable]); // Add a agent policy id to current search const addAgentPolicyFilter = (policyId: string) => { @@ -638,7 +648,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { id="xpack.ingestManager.agentList.loadingAgentsMessage" defaultMessage="Loading agents…" /> - ) : search.trim() || selectedAgentPolicies.length || selectedStatus.length ? ( + ) : isUsingFilter ? ( ({ appContextService: { + getInternalUserSOClient: () => { + return {}; + }, getEncryptedSavedObjects: () => ({ getDecryptedAsInternalUser: () => ({ attributes: { @@ -19,7 +27,83 @@ jest.mock('../../app_context', () => ({ }, })); +jest.mock('../actions'); + +jest.useFakeTimers(); + +function waitForPromiseResolved() { + return new Promise((resolve) => setImmediate(resolve)); +} + +function getMockedNewActionSince() { + return getNewActionsSince as jest.MockedFunction; +} + describe('test agent checkin new action services', () => { + describe('newAgetActionObservable', () => { + beforeEach(() => { + (getNewActionsSince as jest.MockedFunction).mockReset(); + }); + it('should work, call get actions until there is new action', async () => { + const observable = createNewActionsSharedObservable(); + + getMockedNewActionSince() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + ({ id: 'action1', created_at: new Date().toISOString() } as unknown) as AgentAction, + ]) + .mockResolvedValueOnce([ + ({ id: 'action2', created_at: new Date().toISOString() } as unknown) as AgentAction, + ]); + // First call + const promise = observable.pipe(take(1)).toPromise(); + + jest.advanceTimersByTime(5000); + await waitForPromiseResolved(); + jest.advanceTimersByTime(5000); + await waitForPromiseResolved(); + + const res = await promise; + expect(getNewActionsSince).toBeCalledTimes(2); + expect(res).toHaveLength(1); + expect(res[0].id).toBe('action1'); + // Second call + const secondSubscription = observable.pipe(take(1)).toPromise(); + + jest.advanceTimersByTime(5000); + await waitForPromiseResolved(); + + const secondRes = await secondSubscription; + expect(secondRes).toHaveLength(1); + expect(secondRes[0].id).toBe('action2'); + expect(getNewActionsSince).toBeCalledTimes(3); + // It should call getNewActionsSince with the last action returned + expect(getMockedNewActionSince().mock.calls[2][1]).toBe(res[0].created_at); + }); + + it('should not fetch actions concurrently', async () => { + const observable = createNewActionsSharedObservable(); + + const resolves: Array<() => void> = []; + getMockedNewActionSince().mockImplementation(() => { + return new Promise((resolve) => { + resolves.push(resolve); + }); + }); + + observable.pipe(take(1)).toPromise(); + + jest.advanceTimersByTime(5000); + await waitForPromiseResolved(); + jest.advanceTimersByTime(5000); + await waitForPromiseResolved(); + jest.advanceTimersByTime(5000); + await waitForPromiseResolved(); + + expect(getNewActionsSince).toBeCalledTimes(1); + }); + }); + describe('createAgentActionFromPolicyAction()', () => { const mockSavedObjectsClient = savedObjectsClientMock.create(); const mockAgent: Agent = { diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts index 1871cd2cb04f6..aa48d8fe18e9f 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts @@ -12,6 +12,7 @@ import { share, distinctUntilKeyChanged, switchMap, + exhaustMap, concatMap, merge, filter, @@ -62,18 +63,28 @@ function getInternalUserSOClient() { return appContextService.getInternalUserSOClient(fakeRequest); } -function createNewActionsSharedObservable(): Observable { +export function createNewActionsSharedObservable(): Observable { let lastTimestamp = new Date().toISOString(); return timer(0, AGENT_UPDATE_ACTIONS_INTERVAL_MS).pipe( - switchMap(() => { + exhaustMap(() => { const internalSOClient = getInternalUserSOClient(); - const timestamp = lastTimestamp; - lastTimestamp = new Date().toISOString(); - return from(getNewActionsSince(internalSOClient, timestamp)); + return from( + getNewActionsSince(internalSOClient, lastTimestamp).then((data) => { + if (data.length > 0) { + lastTimestamp = data.reduce((acc, action) => { + return acc >= action.created_at ? acc : action.created_at; + }, lastTimestamp); + } + + return data; + }) + ); + }), + filter((data) => { + return data.length > 0; }), - filter((data) => data.length > 0), share() ); } diff --git a/x-pack/plugins/ingest_manager/server/services/package_policy.ts b/x-pack/plugins/ingest_manager/server/services/package_policy.ts index d91f6e8580fc3..dc3a4495191c9 100644 --- a/x-pack/plugins/ingest_manager/server/services/package_policy.ts +++ b/x-pack/plugins/ingest_manager/server/services/package_policy.ts @@ -286,15 +286,15 @@ class PackagePolicyService { for (const id of ids) { try { - const oldPackagePolicy = await this.get(soClient, id); - if (!oldPackagePolicy) { + const packagePolicy = await this.get(soClient, id); + if (!packagePolicy) { throw new Error('Package policy not found'); } if (!options?.skipUnassignFromAgentPolicies) { await agentPolicyService.unassignPackagePolicies( soClient, - oldPackagePolicy.policy_id, - [oldPackagePolicy.id], + packagePolicy.policy_id, + [packagePolicy.id], { user: options?.user, } @@ -303,6 +303,7 @@ class PackagePolicyService { await soClient.delete(SAVED_OBJECT_TYPE, id); result.push({ id, + name: packagePolicy.name, success: true, }); } catch (e) { diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index b7a07e98437a1..57f83b9533bda 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -379,11 +379,13 @@ export function removeSelectedLayer() { ) => { const state = getState(); const layerId = getSelectedLayerId(state); - dispatch(removeLayer(layerId)); + if (layerId) { + dispatch(removeLayer(layerId)); + } }; } -export function removeLayer(layerId: string | null) { +export function removeLayer(layerId: string) { return async ( dispatch: ThunkDispatch, getState: () => MapStoreState @@ -398,7 +400,7 @@ export function removeLayer(layerId: string | null) { }; } -function removeLayerFromLayerList(layerId: string | null) { +function removeLayerFromLayerList(layerId: string) { return ( dispatch: ThunkDispatch, getState: () => MapStoreState @@ -411,7 +413,7 @@ function removeLayerFromLayerList(layerId: string | null) { layerGettingRemoved.getInFlightRequestTokens().forEach((requestToken) => { dispatch(cancelRequest(requestToken)); }); - dispatch(cleanTooltipStateForLayer(layerId!)); + dispatch(cleanTooltipStateForLayer(layerId)); layerGettingRemoved.destroy(); dispatch({ type: REMOVE_LAYER, diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js index 58621b0a70e04..cbb6f0a4054cc 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -27,6 +27,10 @@ describe('map_actions', () => { require('../selectors/map_selectors').getDataFilters = () => { return {}; }; + + require('../selectors/map_selectors').getLayerList = () => { + return []; + }; }); it('should add newMapConstants to dispatch action mapState', async () => { diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 4efdd3dda344e..4e76bb24c9e34 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -18,6 +18,7 @@ import { getWaitingForMapReadyLayerListRaw, getQuery, getTimeFilters, + getLayerList, } from '../selectors/map_selectors'; import { CLEAR_GOTO, @@ -56,6 +57,7 @@ import { } from '../../common/descriptor_types'; import { INITIAL_LOCATION } from '../../common/constants'; import { scaleBounds } from '../../common/elasticsearch_util'; +import { cleanTooltipStateForLayer } from './tooltip_actions'; export function setMapInitError(errorMessage: string) { return { @@ -128,8 +130,7 @@ export function mapExtentChanged(newMapConstants: { zoom: number; extent: MapExt dispatch: ThunkDispatch, getState: () => MapStoreState ) => { - const state = getState(); - const dataFilters = getDataFilters(state); + const dataFilters = getDataFilters(getState()); const { extent, zoom: newZoom } = newMapConstants; const { buffer, zoom: currentZoom } = dataFilters; @@ -164,6 +165,15 @@ export function mapExtentChanged(newMapConstants: { zoom: number; extent: MapExt ...newMapConstants, }, }); + + if (currentZoom !== newZoom) { + getLayerList(getState()).map((layer) => { + if (!layer.showAtZoomLevel(newZoom)) { + dispatch(cleanTooltipStateForLayer(layer.getId())); + } + }); + } + await dispatch(syncDataForAllLayers()); }; } diff --git a/x-pack/plugins/maps/public/actions/tooltip_actions.ts b/x-pack/plugins/maps/public/actions/tooltip_actions.ts index 12451e04efbc3..98a121e6be7a3 100644 --- a/x-pack/plugins/maps/public/actions/tooltip_actions.ts +++ b/x-pack/plugins/maps/public/actions/tooltip_actions.ts @@ -72,7 +72,7 @@ export function openOnHoverTooltip(tooltipState: TooltipState) { }; } -export function cleanTooltipStateForLayer(layerId: string | null, layerFeatures: Feature[] = []) { +export function cleanTooltipStateForLayer(layerId: string, layerFeatures: Feature[] = []) { return (dispatch: Dispatch, getState: () => MapStoreState) => { let featuresRemoved = false; const openTooltips = getOpenTooltips(getState()) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx index 7747fe5746c29..85bd5618300fe 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx @@ -18,6 +18,7 @@ import { EuiLink, EuiSelect, EuiSpacer, + EuiSwitch, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -138,7 +139,7 @@ export const AdvancedStepForm: FC = ({ const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; const { setEstimatedModelMemoryLimit, setFormState } = actions; - const { form, isJobCreated } = state; + const { form, isJobCreated, estimatedModelMemoryLimit } = state; const { computeFeatureInfluence, eta, @@ -159,6 +160,7 @@ export const AdvancedStepForm: FC = ({ outlierFraction, predictionFieldName, randomizeSeed, + useEstimatedMml, } = form; const [numTopClassesOptions, setNumTopClassesOptions] = useState([ @@ -204,7 +206,9 @@ export const AdvancedStepForm: FC = ({ if (success) { if (modelMemoryLimit !== expectedMemory) { setEstimatedModelMemoryLimit(expectedMemory); - setFormState({ modelMemoryLimit: expectedMemory }); + if (useEstimatedMml === true) { + setFormState({ modelMemoryLimit: expectedMemory }); + } } } else { // Check which field is invalid @@ -481,18 +485,35 @@ export const AdvancedStepForm: FC = ({ } )} > - setFormState({ modelMemoryLimit: e.target.value })} - isInvalid={modelMemoryLimitValidationResult !== null} - data-test-subj="mlAnalyticsCreateJobWizardModelMemoryInput" - /> + <> + setFormState({ modelMemoryLimit: e.target.value })} + isInvalid={modelMemoryLimitValidationResult !== null} + data-test-subj="mlAnalyticsCreateJobWizardModelMemoryInput" + /> + + + setFormState({ + useEstimatedMml: !useEstimatedMml, + }) + } + data-test-subj="mlAnalyticsCreateJobWizardUseEstimatedMml" + /> + diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index dd9ecc963840a..9c166f32f1d34 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -91,6 +91,7 @@ export const ConfigurationStepForm: FC = ({ requiredFieldsError, sourceIndex, trainingPercent, + useEstimatedMml, } = form; const toastNotifications = getToastNotifications(); @@ -164,7 +165,8 @@ export const ConfigurationStepForm: FC = ({ const debouncedGetExplainData = debounce(async () => { const jobTypeChanged = previousJobType !== jobType; - const shouldUpdateModelMemoryLimit = !firstUpdate.current || !modelMemoryLimit; + const shouldUpdateModelMemoryLimit = + (!firstUpdate.current || !modelMemoryLimit) && useEstimatedMml === true; const shouldUpdateEstimatedMml = !firstUpdate.current || !modelMemoryLimit || estimatedModelMemoryLimit === ''; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 898bd7aa183cd..533a5eb6f5c34 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -93,6 +93,7 @@ export interface State { sourceIndexFieldsCheckFailed: boolean; standardizationEnabled: undefined | string; trainingPercent: number; + useEstimatedMml: boolean; }; disabled: boolean; indexPatternsMap: SourceIndexMap; @@ -161,6 +162,7 @@ export const getInitialState = (): State => ({ sourceIndexFieldsCheckFailed: false, standardizationEnabled: 'true', trainingPercent: 80, + useEstimatedMml: true, }, jobConfig: {}, disabled: diff --git a/x-pack/plugins/monitoring/public/services/clusters.js b/x-pack/plugins/monitoring/public/services/clusters.js index d0ca1bc6bbde6..ef97d78b4f745 100644 --- a/x-pack/plugins/monitoring/public/services/clusters.js +++ b/x-pack/plugins/monitoring/public/services/clusters.js @@ -25,7 +25,7 @@ let once = false; let inTransit = false; export function monitoringClustersProvider($injector) { - return (clusterUuid, ccs, codePaths) => { + return async (clusterUuid, ccs, codePaths) => { const { min, max } = Legacy.shims.timefilter.getBounds(); // append clusterUuid if the parameter is given @@ -36,74 +36,73 @@ export function monitoringClustersProvider($injector) { const $http = $injector.get('$http'); - function getClusters() { - return $http - .post(url, { + async function getClusters() { + try { + const response = await $http.post(url, { ccs, timeRange: { min: min.toISOString(), max: max.toISOString(), }, codePaths, - }) - .then((response) => response.data) - .then((data) => { - return formatClusters(data); // return set of clusters - }) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); }); + return formatClusters(response.data); + } catch (err) { + const Private = $injector.get('Private'); + const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); + return ajaxErrorHandlers(err); + } } - function ensureAlertsEnabled() { - return $http.post('../api/monitoring/v1/alerts/enable', {}).catch((err) => { + async function ensureAlertsEnabled() { + try { + return $http.post('../api/monitoring/v1/alerts/enable', {}); + } catch (err) { const Private = $injector.get('Private'); const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); return ajaxErrorHandlers(err); - }); + } } - function ensureMetricbeatEnabled() { + async function ensureMetricbeatEnabled() { if (Legacy.shims.isCloud) { - return Promise.resolve(); + return; } const globalState = $injector.get('globalState'); - return $http - .post('../api/monitoring/v1/elasticsearch_settings/check/internal_monitoring', { - ccs: globalState.ccs, - }) - .then(({ data }) => { - showInternalMonitoringToast({ - legacyIndices: data.legacy_indices, - metricbeatIndices: data.mb_indices, - }); - }) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); + try { + const response = await $http.post( + '../api/monitoring/v1/elasticsearch_settings/check/internal_monitoring', + { + ccs: globalState.ccs, + } + ); + const { data } = response; + showInternalMonitoringToast({ + legacyIndices: data.legacy_indices, + metricbeatIndices: data.mb_indices, }); + } catch (err) { + const Private = $injector.get('Private'); + const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); + return ajaxErrorHandlers(err); + } } if (!once && !inTransit) { inTransit = true; - return getClusters().then((clusters) => { - if (clusters.length) { - Promise.all([ensureAlertsEnabled(), ensureMetricbeatEnabled()]) - .then(([{ data }]) => { - showSecurityToast(data); - once = true; - }) - .catch(() => { - // Intentionally swallow the error as this will retry the next page load - }) - .finally(() => (inTransit = false)); + const clusters = await getClusters(); + if (clusters.length) { + try { + const [{ data }] = await Promise.all([ensureAlertsEnabled(), ensureMetricbeatEnabled()]); + showSecurityToast(data); + once = true; + } catch (_err) { + // Intentionally swallow the error as this will retry the next page load } - return clusters; - }); + inTransit = false; + } + return clusters; } - return getClusters(); + return await getClusters(); }; } diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js index 4341ada00da8b..e69d572f9560b 100644 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js +++ b/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js @@ -63,7 +63,7 @@ uiRoutes.when('/elasticsearch/nodes', { const promise = globalState.cluster_uuid ? getNodes() - : new Promise((resolve) => resolve({})); + : new Promise((resolve) => resolve({ data: {} })); return promise .then((response) => response.data) .catch((err) => { diff --git a/x-pack/plugins/monitoring/public/views/loading/index.js b/x-pack/plugins/monitoring/public/views/loading/index.js index 5ca523899067d..b5d79e1d2b652 100644 --- a/x-pack/plugins/monitoring/public/views/loading/index.js +++ b/x-pack/plugins/monitoring/public/views/loading/index.js @@ -53,15 +53,18 @@ uiRoutes.when('/loading', { (clusters) => { if (!clusters || !clusters.length) { window.location.hash = '#/no-data'; + $scope.$apply(); return; } if (clusters.length === 1) { // Bypass the cluster listing if there is just 1 cluster window.history.replaceState(null, null, '#/overview'); + $scope.$apply(); return; } window.history.replaceState(null, null, '#/home'); + $scope.$apply(); } ); } diff --git a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts index e3d69820ebb05..5605641992e1a 100644 --- a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts @@ -36,7 +36,7 @@ describe('DiskUsageAlert', () => { expect(alert.type).toBe(ALERT_DISK_USAGE); expect(alert.label).toBe('Disk Usage'); expect(alert.defaultThrottle).toBe('1d'); - expect(alert.defaultParams).toStrictEqual({ threshold: 90, duration: '5m' }); + expect(alert.defaultParams).toStrictEqual({ threshold: 80, duration: '5m' }); expect(alert.actionVariables).toStrictEqual([ { name: 'nodes', description: 'The list of nodes reporting high disk usage.' }, { name: 'count', description: 'The number of nodes reporting high disk usage.' }, diff --git a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts index c577550de8617..34c640de79625 100644 --- a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts @@ -54,7 +54,7 @@ export class DiskUsageAlert extends BaseAlert { public label = DiskUsageAlert.LABEL; protected defaultParams = { - threshold: 90, + threshold: 80, duration: '5m', }; diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts index 6ed237a055b5c..57d01dc6a1100 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts @@ -22,9 +22,9 @@ describe('MissingMonitoringDataAlert', () => { const alert = new MissingMonitoringDataAlert(); expect(alert.type).toBe(ALERT_MISSING_MONITORING_DATA); expect(alert.label).toBe('Missing monitoring data'); - expect(alert.defaultThrottle).toBe('1d'); + expect(alert.defaultThrottle).toBe('6h'); // @ts-ignore - expect(alert.defaultParams).toStrictEqual({ limit: '1d', duration: '5m' }); + expect(alert.defaultParams).toStrictEqual({ limit: '1d', duration: '15m' }); // @ts-ignore expect(alert.actionVariables).toStrictEqual([ { name: 'stackProducts', description: 'The stack products missing monitoring data.' }, diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts index 252d005f1e4a6..5b4542a4439ca 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts @@ -50,7 +50,7 @@ const FIRING = i18n.translate('xpack.monitoring.alerts.missingData.firing', { defaultMessage: 'firing', }); -const DEFAULT_DURATION = '5m'; +const DEFAULT_DURATION = '15m'; const DEFAULT_LIMIT = '1d'; // Go a bit farther back because we need to detect the difference between seeing the monitoring data versus just not looking far enough back @@ -77,6 +77,8 @@ export class MissingMonitoringDataAlert extends BaseAlert { } as CommonAlertParamDetail, }; + public defaultThrottle: string = '6h'; + public type = ALERT_MISSING_MONITORING_DATA; public label = i18n.translate('xpack.monitoring.alerts.missingData.label', { defaultMessage: 'Missing monitoring data', diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index 2cf5b69835d1f..39f597acec6ec 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -12,8 +12,7 @@ import { LevelLogger } from '../../../lib'; import { createLayout, LayoutParams } from '../../../lib/layouts'; import { ScreenshotResults } from '../../../lib/screenshots'; import { ConditionalHeaders } from '../../common'; -// @ts-ignore untyped module -import { pdf } from './pdf'; +import { PdfMaker } from './pdf'; import { getTracker } from './tracker'; const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { @@ -58,7 +57,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { tracker.endScreenshots(); tracker.startSetup(); - const pdfOutput = pdf.create(layout, logo); + const pdfOutput = new PdfMaker(layout, logo); if (title) { const timeRange = getTimeRange(results); title += timeRange ? ` - ${timeRange}` : ''; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts index 9f7e9310333ba..fe687c3f47327 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts @@ -31,7 +31,7 @@ test(`gets logo from uiSettings`, async () => { }; const mockGet = jest.fn(); - mockGet.mockImplementationOnce((...args: any[]) => { + mockGet.mockImplementationOnce((...args: string[]) => { if (args[0] === 'xpackReporting:customPdfLogo') { return 'purple pony'; } diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts new file mode 100644 index 0000000000000..5a3671835ce51 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BufferOptions } from 'pdfmake/interfaces'; + +export function getDocOptions(tableBorderWidth: number): BufferOptions { + return { + tableLayouts: { + noBorder: { + // format is function (i, node) { ... }; + hLineWidth: () => 0, + vLineWidth: () => 0, + paddingLeft: () => 0, + paddingRight: () => 0, + paddingTop: () => 0, + paddingBottom: () => 0, + }, + simpleBorder: { + // format is function (i, node) { ... }; + hLineWidth: () => tableBorderWidth, + vLineWidth: () => tableBorderWidth, + hLineColor: () => 'silver', + vLineColor: () => 'silver', + paddingLeft: () => 0, + paddingRight: () => 0, + paddingTop: () => 0, + paddingBottom: () => 0, + }, + }, + }; +} diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts new file mode 100644 index 0000000000000..5cd6136153f04 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-ignore: no module definition +import xRegExp from 'xregexp'; + +export function getFont(text: string) { + // Once unicode regex scripts are fully supported we should be able to get rid of the dependency + // on xRegExp library. See https://github.com/tc39/proposal-regexp-unicode-property-escapes + // for more information. We are matching Han characters which is one of the supported unicode scripts + // (you can see the full list of supported scripts here: http://www.unicode.org/standard/supported.html). + // This will match Chinese, Japanese, Korean and some other Asian languages. + const isCKJ = xRegExp('\\p{Han}').test(text, 'g'); + if (isCKJ) { + return 'noto-cjk'; + } else { + return 'Roboto'; + } +} diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts new file mode 100644 index 0000000000000..131d289576384 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import path from 'path'; +import { TDocumentDefinitions } from 'pdfmake/interfaces'; +import { LayoutInstance } from '../../../../lib/layouts'; +import { getFont } from './get_font'; + +export function getTemplate( + layout: LayoutInstance, + logo: string | undefined, + title: string, + tableBorderWidth: number, + assetPath: string +): Partial { + const pageMarginTop = 40; + const pageMarginBottom = 80; + const pageMarginWidth = 40; + const headingFontSize = 14; + const headingMarginTop = 10; + const headingMarginBottom = 5; + const headingHeight = headingFontSize * 1.5 + headingMarginTop + headingMarginBottom; + const subheadingFontSize = 12; + const subheadingMarginTop = 0; + const subheadingMarginBottom = 5; + const subheadingHeight = subheadingFontSize * 1.5 + subheadingMarginTop + subheadingMarginBottom; + + return { + // define page size + pageOrientation: layout.getPdfPageOrientation(), + pageSize: layout.getPdfPageSize({ + pageMarginTop, + pageMarginBottom, + pageMarginWidth, + tableBorderWidth, + headingHeight, + subheadingHeight, + }), + pageMargins: [pageMarginWidth, pageMarginTop, pageMarginWidth, pageMarginBottom], + + header() { + return { + margin: [pageMarginWidth, pageMarginTop / 4, pageMarginWidth, 0], + text: title, + font: getFont(title), + style: { + color: '#aaa', + }, + fontSize: 10, + alignment: 'center', + }; + }, + + footer(currentPage: number, pageCount: number) { + const logoPath = path.resolve(assetPath, 'img', 'logo-grey.png'); // Default Elastic Logo + return { + margin: [pageMarginWidth, pageMarginBottom / 4, pageMarginWidth, 0], + layout: 'noBorder', + table: { + widths: [100, '*', 100], + body: [ + [ + { + fit: [100, 35], + image: logo || logoPath, + }, + { + alignment: 'center', + text: i18n.translate('xpack.reporting.exportTypes.printablePdf.pagingDescription', { + defaultMessage: 'Page {currentPage} of {pageCount}', + values: { currentPage: currentPage.toString(), pageCount }, + }), + style: { + color: '#aaa', + }, + }, + '', + ], + [ + logo + ? { + text: i18n.translate( + 'xpack.reporting.exportTypes.printablePdf.logoDescription', + { + defaultMessage: 'Powered by Elastic', + } + ), + fontSize: 10, + style: { + color: '#aaa', + }, + margin: [0, 2, 0, 0], + } + : '', + '', + '', + ], + ], + }, + }; + }, + + styles: { + heading: { + alignment: 'left', + fontSize: headingFontSize, + bold: true, + margin: [headingMarginTop, 0, headingMarginBottom, 0], + }, + subheading: { + alignment: 'left', + fontSize: subheadingFontSize, + italics: true, + margin: [0, 0, subheadingMarginBottom, 20], + }, + warning: { + color: '#f39c12', // same as @brand-warning in Kibana colors.less + }, + }, + + defaultStyle: { + fontSize: 12, + font: 'Roboto', + }, + }; +} diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js deleted file mode 100644 index 8840fd524f3e4..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js +++ /dev/null @@ -1,319 +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 path from 'path'; -import _ from 'lodash'; -import concat from 'concat-stream'; -import Printer from 'pdfmake'; -import xRegExp from 'xregexp'; -import { i18n } from '@kbn/i18n'; - -const assetPath = path.resolve(__dirname, '..', '..', '..', 'common', 'assets'); - -const tableBorderWidth = 1; - -function getFont(text) { - // Once unicode regex scripts are fully supported we should be able to get rid of the dependency - // on xRegExp library. See https://github.com/tc39/proposal-regexp-unicode-property-escapes - // for more information. We are matching Han characters which is one of the supported unicode scripts - // (you can see the full list of supported scripts here: http://www.unicode.org/standard/supported.html). - // This will match Chinese, Japanese, Korean and some other Asian languages. - const isCKJ = xRegExp('\\p{Han}').test(text, 'g'); - if (isCKJ) { - return 'noto-cjk'; - } else { - return 'Roboto'; - } -} - -class PdfMaker { - constructor(layout, logo) { - const fontPath = (filename) => path.resolve(assetPath, 'fonts', filename); - const fonts = { - Roboto: { - normal: fontPath('roboto/Roboto-Regular.ttf'), - bold: fontPath('roboto/Roboto-Medium.ttf'), - italics: fontPath('roboto/Roboto-Italic.ttf'), - bolditalics: fontPath('roboto/Roboto-Italic.ttf'), - }, - 'noto-cjk': { - // Roboto does not support CJK characters, so we'll fall back on this font if we detect them. - normal: fontPath('noto/NotoSansCJKtc-Regular.ttf'), - bold: fontPath('noto/NotoSansCJKtc-Medium.ttf'), - italics: fontPath('noto/NotoSansCJKtc-Regular.ttf'), - bolditalics: fontPath('noto/NotoSansCJKtc-Medium.ttf'), - }, - }; - - this._layout = layout; - this._logo = logo; - this._title = ''; - this._content = []; - this._printer = new Printer(fonts); - } - - _addContents(contents) { - const groupCount = this._content.length; - - // inject a page break for every 2 groups on the page - if (groupCount > 0 && groupCount % this._layout.groupCount === 0) { - contents = [ - { - text: '', - pageBreak: 'after', - }, - ].concat(contents); - } - this._content.push(contents); - } - - addImage(base64EncodedData, { title = '', description = '' }) { - const contents = []; - - if (title && title.length > 0) { - contents.push({ - text: title, - style: 'heading', - font: getFont(title), - noWrap: true, - }); - } - - if (description && description.length > 0) { - contents.push({ - text: description, - style: 'subheading', - font: getFont(description), - noWrap: true, - }); - } - - const img = { - image: `data:image/png;base64,${base64EncodedData}`, - alignment: 'center', - }; - - const size = this._layout.getPdfImageSize(); - img.height = size.height; - img.width = size.width; - - const wrappedImg = { - table: { - body: [[img]], - }, - layout: 'noBorder', - }; - - contents.push(wrappedImg); - - this._addContents(contents); - } - - addHeading(headingText, opts = {}) { - const contents = []; - contents.push({ - text: headingText, - style: ['heading'].concat(opts.styles || []), - font: getFont(headingText), - }); - this._addContents(contents); - } - - setTitle(title) { - this._title = title; - } - - generate() { - const docTemplate = _.assign(getTemplate(this._layout, this._logo, this._title), { - content: this._content, - }); - this._pdfDoc = this._printer.createPdfKitDocument(docTemplate, getDocOptions()); - return this; - } - - getBuffer() { - if (!this._pdfDoc) { - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.printablePdf.documentStreamIsNotgeneratedErrorMessage', - { - defaultMessage: 'Document stream has not been generated', - } - ) - ); - } - return new Promise((resolve, reject) => { - const concatStream = concat(function (pdfBuffer) { - resolve(pdfBuffer); - }); - - this._pdfDoc.on('error', reject); - this._pdfDoc.pipe(concatStream); - this._pdfDoc.end(); - }); - } - - getStream() { - if (!this._pdfDoc) { - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.printablePdf.documentStreamIsNotgeneratedErrorMessage', - { - defaultMessage: 'Document stream has not been generated', - } - ) - ); - } - this._pdfDoc.end(); - return this._pdfDoc; - } -} - -function getTemplate(layout, logo, title) { - const pageMarginTop = 40; - const pageMarginBottom = 80; - const pageMarginWidth = 40; - const headingFontSize = 14; - const headingMarginTop = 10; - const headingMarginBottom = 5; - const headingHeight = headingFontSize * 1.5 + headingMarginTop + headingMarginBottom; - const subheadingFontSize = 12; - const subheadingMarginTop = 0; - const subheadingMarginBottom = 5; - const subheadingHeight = subheadingFontSize * 1.5 + subheadingMarginTop + subheadingMarginBottom; - - return { - // define page size - pageOrientation: layout.getPdfPageOrientation(), - pageSize: layout.getPdfPageSize({ - pageMarginTop, - pageMarginBottom, - pageMarginWidth, - tableBorderWidth, - headingHeight, - subheadingHeight, - }), - pageMargins: [pageMarginWidth, pageMarginTop, pageMarginWidth, pageMarginBottom], - - header: function () { - return { - margin: [pageMarginWidth, pageMarginTop / 4, pageMarginWidth, 0], - text: title, - font: getFont(title), - style: { - color: '#aaa', - }, - fontSize: 10, - alignment: 'center', - }; - }, - - footer: function (currentPage, pageCount) { - const logoPath = path.resolve(assetPath, 'img', 'logo-grey.png'); - return { - margin: [pageMarginWidth, pageMarginBottom / 4, pageMarginWidth, 0], - layout: 'noBorder', - table: { - widths: [100, '*', 100], - body: [ - [ - { - fit: [100, 35], - image: logo || logoPath, - }, - { - alignment: 'center', - text: i18n.translate('xpack.reporting.exportTypes.printablePdf.pagingDescription', { - defaultMessage: 'Page {currentPage} of {pageCount}', - values: { currentPage: currentPage.toString(), pageCount }, - }), - style: { - color: '#aaa', - }, - }, - '', - ], - [ - logo - ? { - text: i18n.translate( - 'xpack.reporting.exportTypes.printablePdf.logoDescription', - { - defaultMessage: 'Powered by Elastic', - } - ), - fontSize: 10, - style: { - color: '#aaa', - }, - margin: [0, 2, 0, 0], - } - : '', - '', - '', - ], - ], - }, - }; - }, - - styles: { - heading: { - alignment: 'left', - fontSize: headingFontSize, - bold: true, - marginTop: headingMarginTop, - marginBottom: headingMarginBottom, - }, - subheading: { - alignment: 'left', - fontSize: subheadingFontSize, - italics: true, - marginLeft: 20, - marginBottom: subheadingMarginBottom, - }, - warning: { - color: '#f39c12', // same as @brand-warning in Kibana colors.less - }, - }, - - defaultStyle: { - fontSize: 12, - font: 'Roboto', - }, - }; -} - -function getDocOptions() { - return { - tableLayouts: { - noBorder: { - // format is function (i, node) { ... }; - hLineWidth: () => 0, - vLineWidth: () => 0, - paddingLeft: () => 0, - paddingRight: () => 0, - paddingTop: () => 0, - paddingBottom: () => 0, - }, - simpleBorder: { - // format is function (i, node) { ... }; - hLineWidth: () => tableBorderWidth, - vLineWidth: () => tableBorderWidth, - hLineColor: () => 'silver', - vLineColor: () => 'silver', - paddingLeft: () => 0, - paddingRight: () => 0, - paddingTop: () => 0, - paddingBottom: () => 0, - }, - }, - }; -} - -export const pdf = { - create: (layout, logo) => new PdfMaker(layout, logo), -}; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.test.ts new file mode 100644 index 0000000000000..5c6a7c7c63c69 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PreserveLayout, PrintLayout } from '../../../../lib/layouts'; +import { createMockConfig, createMockConfigSchema } from '../../../../test_helpers'; +import { PdfMaker } from './'; + +const imageBase64 = `iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAGFBMVEXy8vJpaWn7+/vY2Nj39/cAAACcnJzx8fFvt0oZAAAAi0lEQVR4nO3SSQoDIBBFwR7U3P/GQXKEIIJULXr9H3TMrHhX5Yysvj3jjM8+XRnVa9wec8QuHKv3h74Z+PNyGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/xu3Bxy026rXu4ljdUVW395xUFfGzLo946DK+QW+bgCTFcecSAAAAABJRU5ErkJggg==`; + +describe('PdfMaker', () => { + it('makes PDF using PrintLayout mode', async () => { + const config = createMockConfig(createMockConfigSchema()); + const layout = new PrintLayout(config.get('capture')); + const pdf = new PdfMaker(layout, undefined); + + expect(pdf.setTitle('the best PDF in the world')).toBe(undefined); + expect([ + pdf.addImage(imageBase64, { title: 'first viz', description: '☃️' }), + pdf.addImage(imageBase64, { title: 'second viz', description: '❄️' }), + ]).toEqual([undefined, undefined]); + + const { _layout: testLayout, _title: testTitle } = (pdf as unknown) as { + _layout: object; + _title: string; + }; + expect(testLayout).toMatchObject({ + captureConfig: { browser: { chromium: { disableSandbox: true } } }, // NOTE: irrelevant data? + groupCount: 2, + id: 'print', + selectors: { + itemsCountAttribute: 'data-shared-items-count', + renderComplete: '[data-shared-item]', + screenshot: '[data-shared-item]', + timefilterDurationAttribute: 'data-shared-timefilter-duration', + }, + }); + expect(testTitle).toBe('the best PDF in the world'); + + // generate buffer + pdf.generate(); + const result = await pdf.getBuffer(); + expect(Buffer.isBuffer(result)).toBe(true); + }); + + it('makes PDF using PreserveLayout mode', async () => { + const layout = new PreserveLayout({ width: 400, height: 300 }); + const pdf = new PdfMaker(layout, undefined); + + expect(pdf.setTitle('the finest PDF in the world')).toBe(undefined); + expect(pdf.addImage(imageBase64, { title: 'cool times', description: '☃️' })).toBe(undefined); + + const { _layout: testLayout, _title: testTitle } = (pdf as unknown) as { + _layout: object; + _title: string; + }; + expect(testLayout).toMatchObject({ + groupCount: 1, + id: 'preserve_layout', + selectors: { + itemsCountAttribute: 'data-shared-items-count', + renderComplete: '[data-shared-item]', + screenshot: '[data-shared-items-container]', + timefilterDurationAttribute: 'data-shared-timefilter-duration', + }, + }); + expect(testTitle).toBe('the finest PDF in the world'); + + // generate buffer + pdf.generate(); + const result = await pdf.getBuffer(); + expect(Buffer.isBuffer(result)).toBe(true); + }); +}); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts new file mode 100644 index 0000000000000..c58ceae85657b --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +// @ts-ignore: no module definition +import concat from 'concat-stream'; +import _ from 'lodash'; +import path from 'path'; +import Printer from 'pdfmake'; +import { Content, ContentText } from 'pdfmake/interfaces'; +import { LayoutInstance } from '../../../../lib/layouts'; +import { getDocOptions } from './get_doc_options'; +import { getFont } from './get_font'; +import { getTemplate } from './get_template'; + +const assetPath = path.resolve(__dirname, '..', '..', '..', 'common', 'assets'); +const tableBorderWidth = 1; + +export class PdfMaker { + private _layout: LayoutInstance; + private _logo: string | undefined; + private _title: string; + private _content: Content[]; + private _printer: Printer; + private _pdfDoc: PDFKit.PDFDocument | undefined; + + constructor(layout: LayoutInstance, logo: string | undefined) { + const fontPath = (filename: string) => path.resolve(assetPath, 'fonts', filename); + const fonts = { + Roboto: { + normal: fontPath('roboto/Roboto-Regular.ttf'), + bold: fontPath('roboto/Roboto-Medium.ttf'), + italics: fontPath('roboto/Roboto-Italic.ttf'), + bolditalics: fontPath('roboto/Roboto-Italic.ttf'), + }, + 'noto-cjk': { + // Roboto does not support CJK characters, so we'll fall back on this font if we detect them. + normal: fontPath('noto/NotoSansCJKtc-Regular.ttf'), + bold: fontPath('noto/NotoSansCJKtc-Medium.ttf'), + italics: fontPath('noto/NotoSansCJKtc-Regular.ttf'), + bolditalics: fontPath('noto/NotoSansCJKtc-Medium.ttf'), + }, + }; + + this._layout = layout; + this._logo = logo; + this._title = ''; + this._content = []; + this._printer = new Printer(fonts); + } + + _addContents(contents: Content[]) { + const groupCount = this._content.length; + + // inject a page break for every 2 groups on the page + if (groupCount > 0 && groupCount % this._layout.groupCount === 0) { + contents = [ + ({ + text: '', + pageBreak: 'after', + } as ContentText) as Content, + ].concat(contents); + } + this._content.push(contents); + } + + addImage(base64EncodedData: string, { title = '', description = '' }) { + const contents: Content[] = []; + + if (title && title.length > 0) { + contents.push({ + text: title, + style: 'heading', + font: getFont(title), + noWrap: true, + }); + } + + if (description && description.length > 0) { + contents.push({ + text: description, + style: 'subheading', + font: getFont(description), + noWrap: true, + }); + } + + const size = this._layout.getPdfImageSize(); + const img = { + image: `data:image/png;base64,${base64EncodedData}`, + alignment: 'center', + height: size.height, + width: size.width, + }; + + const wrappedImg = { + table: { + body: [[img]], + }, + layout: 'noBorder', + }; + + contents.push(wrappedImg); + + this._addContents(contents); + } + + setTitle(title: string) { + this._title = title; + } + + generate() { + const docTemplate = _.assign( + getTemplate(this._layout, this._logo, this._title, tableBorderWidth, assetPath), + { + content: this._content, + } + ); + this._pdfDoc = this._printer.createPdfKitDocument(docTemplate, getDocOptions(tableBorderWidth)); + return this; + } + + getBuffer(): Promise { + return new Promise((resolve, reject) => { + if (!this._pdfDoc) { + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.printablePdf.documentStreamIsNotgeneratedErrorMessage', + { + defaultMessage: 'Document stream has not been generated', + } + ) + ); + } + + const concatStream = concat(function (pdfBuffer: Buffer) { + resolve(pdfBuffer); + }); + + this._pdfDoc.on('error', reject); + this._pdfDoc.pipe(concatStream); + this._pdfDoc.end(); + }); + } +} diff --git a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts index 585175aac82c5..e69b8d61dec0d 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts @@ -5,11 +5,14 @@ */ import { CaptureConfig } from '../../types'; -import { LayoutParams, LayoutTypes } from './'; +import { LayoutInstance, LayoutParams, LayoutTypes } from './'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; -export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams) { +export function createLayout( + captureConfig: CaptureConfig, + layoutParams?: LayoutParams +): LayoutInstance { if (layoutParams && layoutParams.dimensions && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { return new PreserveLayout(layoutParams.dimensions); } diff --git a/x-pack/plugins/reporting/server/lib/layouts/layout.ts b/x-pack/plugins/reporting/server/lib/layouts/layout.ts index 433edb35df4cd..4dd4003c269c0 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/layout.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { CustomPageSize, PredefinedPageSize } from 'pdfmake/interfaces'; import { PageSizeParams, PdfImageSize, Size } from './'; export interface ViewZoomWidthHeight { @@ -14,6 +15,7 @@ export interface ViewZoomWidthHeight { export abstract class Layout { public id: string = ''; + public groupCount: number = 0; constructor(id: string) { this.id = id; @@ -21,9 +23,11 @@ export abstract class Layout { public abstract getPdfImageSize(): PdfImageSize; - public abstract getPdfPageOrientation(): string | undefined; + public abstract getPdfPageOrientation(): 'portrait' | 'landscape' | undefined; - public abstract getPdfPageSize(pageSizeParams: PageSizeParams): string | Size; + public abstract getPdfPageSize( + pageSizeParams: PageSizeParams + ): CustomPageSize | PredefinedPageSize; public abstract getViewport(itemsCount: number): ViewZoomWidthHeight | null; diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts index cecd761fbcf32..faddaae64ce5d 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts @@ -5,14 +5,15 @@ */ import path from 'path'; +import { CustomPageSize } from 'pdfmake/interfaces'; import { getDefaultLayoutSelectors, Layout, + LayoutInstance, LayoutSelectorDictionary, LayoutTypes, PageSizeParams, Size, - LayoutInstance, } from './'; // We use a zoom of two to bump up the resolution of the screenshot a bit. @@ -72,7 +73,7 @@ export class PreserveLayout extends Layout implements LayoutInstance { return undefined; } - public getPdfPageSize(pageSizeParams: PageSizeParams) { + public getPdfPageSize(pageSizeParams: PageSizeParams): CustomPageSize { return { height: this.height + diff --git a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts index 33f16bc7865d5..e979cdeeb71fe 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts @@ -5,6 +5,7 @@ */ import path from 'path'; +import { PageOrientation, PredefinedPageSize } from 'pdfmake/interfaces'; import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; import { LevelLogger } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; @@ -90,11 +91,11 @@ export class PrintLayout extends Layout implements LayoutInstance { }; } - public getPdfPageOrientation() { + public getPdfPageOrientation(): PageOrientation { return 'portrait'; } - public getPdfPageSize() { + public getPdfPageSize(): PredefinedPageSize { return 'A4'; } } diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 4c9e3bc06037e..c3fc6bd1ae1dd 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -5,7 +5,7 @@ "private": true, "license": "Elastic-License", "scripts": { - "extract-mitre-attacks": "node scripts/extract_tactics_techniques_mitre.js && node ../../../scripts/eslint ./public/pages/detection_engine/mitre/mitre_tactics_techniques.ts --fix", + "extract-mitre-attacks": "node scripts/extract_tactics_techniques_mitre.js && node ../../../scripts/eslint ./public/detections/mitre/mitre_tactics_techniques.ts --fix", "build-beat-doc": "node scripts/beat_docs/build.js && node ../../../scripts/eslint ./server/utils/beat_schema/fields.ts --fix", "build-graphql-types": "node scripts/generate_types_from_graphql.js", "cypress:open": "cypress open --config-file ./cypress/cypress.json", diff --git a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts index fb8deeec8309c..027aa7fd699e4 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts @@ -78,9 +78,7 @@ export const tacticsOptions: MitreTacticsOptions[] = [ reference: 'https://attack.mitre.org/tactics/TA0009', text: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTactics.collectionDescription', - { - defaultMessage: 'Collection (TA0009)', - } + { defaultMessage: 'Collection (TA0009)' } ), value: 'collection', }, @@ -120,9 +118,7 @@ export const tacticsOptions: MitreTacticsOptions[] = [ reference: 'https://attack.mitre.org/tactics/TA0007', text: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTactics.discoveryDescription', - { - defaultMessage: 'Discovery (TA0007)', - } + { defaultMessage: 'Discovery (TA0007)' } ), value: 'discovery', }, @@ -132,9 +128,7 @@ export const tacticsOptions: MitreTacticsOptions[] = [ reference: 'https://attack.mitre.org/tactics/TA0002', text: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTactics.executionDescription', - { - defaultMessage: 'Execution (TA0002)', - } + { defaultMessage: 'Execution (TA0002)' } ), value: 'execution', }, @@ -144,9 +138,7 @@ export const tacticsOptions: MitreTacticsOptions[] = [ reference: 'https://attack.mitre.org/tactics/TA0010', text: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTactics.exfiltrationDescription', - { - defaultMessage: 'Exfiltration (TA0010)', - } + { defaultMessage: 'Exfiltration (TA0010)' } ), value: 'exfiltration', }, @@ -156,9 +148,7 @@ export const tacticsOptions: MitreTacticsOptions[] = [ reference: 'https://attack.mitre.org/tactics/TA0040', text: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTactics.impactDescription', - { - defaultMessage: 'Impact (TA0040)', - } + { defaultMessage: 'Impact (TA0040)' } ), value: 'impact', }, @@ -168,9 +158,7 @@ export const tacticsOptions: MitreTacticsOptions[] = [ reference: 'https://attack.mitre.org/tactics/TA0001', text: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTactics.initialAccessDescription', - { - defaultMessage: 'Initial Access (TA0001)', - } + { defaultMessage: 'Initial Access (TA0001)' } ), value: 'initialAccess', }, @@ -190,9 +178,7 @@ export const tacticsOptions: MitreTacticsOptions[] = [ reference: 'https://attack.mitre.org/tactics/TA0003', text: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTactics.persistenceDescription', - { - defaultMessage: 'Persistence (TA0003)', - } + { defaultMessage: 'Persistence (TA0003)' } ), value: 'persistence', }, @@ -1998,9 +1984,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.bitsJobsDescription', - { - defaultMessage: 'BITS Jobs (T1197)', - } + { defaultMessage: 'BITS Jobs (T1197)' } ), id: 'T1197', name: 'BITS Jobs', @@ -2033,9 +2017,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.bootkitDescription', - { - defaultMessage: 'Bootkit (T1067)', - } + { defaultMessage: 'Bootkit (T1067)' } ), id: 'T1067', name: 'Bootkit', @@ -2090,9 +2072,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.cmstpDescription', - { - defaultMessage: 'CMSTP (T1191)', - } + { defaultMessage: 'CMSTP (T1191)' } ), id: 'T1191', name: 'CMSTP', @@ -2367,9 +2347,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.dcShadowDescription', - { - defaultMessage: 'DCShadow (T1207)', - } + { defaultMessage: 'DCShadow (T1207)' } ), id: 'T1207', name: 'DCShadow', @@ -2688,9 +2666,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.emondDescription', - { - defaultMessage: 'Emond (T1519)', - } + { defaultMessage: 'Emond (T1519)' } ), id: 'T1519', name: 'Emond', @@ -3053,9 +3029,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.hookingDescription', - { - defaultMessage: 'Hooking (T1179)', - } + { defaultMessage: 'Hooking (T1179)' } ), id: 'T1179', name: 'Hooking', @@ -3231,9 +3205,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.keychainDescription', - { - defaultMessage: 'Keychain (T1142)', - } + { defaultMessage: 'Keychain (T1142)' } ), id: 'T1142', name: 'Keychain', @@ -3310,9 +3282,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.launchctlDescription', - { - defaultMessage: 'Launchctl (T1152)', - } + { defaultMessage: 'Launchctl (T1152)' } ), id: 'T1152', name: 'Launchctl', @@ -3334,9 +3304,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.loginItemDescription', - { - defaultMessage: 'Login Item (T1162)', - } + { defaultMessage: 'Login Item (T1162)' } ), id: 'T1162', name: 'Login Item', @@ -3402,9 +3370,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.mshtaDescription', - { - defaultMessage: 'Mshta (T1170)', - } + { defaultMessage: 'Mshta (T1170)' } ), id: 'T1170', name: 'Mshta', @@ -3778,9 +3744,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.rcCommonDescription', - { - defaultMessage: 'Rc.common (T1163)', - } + { defaultMessage: 'Rc.common (T1163)' } ), id: 'T1163', name: 'Rc.common', @@ -3835,9 +3799,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.regsvr32Description', - { - defaultMessage: 'Regsvr32 (T1117)', - } + { defaultMessage: 'Regsvr32 (T1117)' } ), id: 'T1117', name: 'Regsvr32', @@ -3936,9 +3898,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.rootkitDescription', - { - defaultMessage: 'Rootkit (T1014)', - } + { defaultMessage: 'Rootkit (T1014)' } ), id: 'T1014', name: 'Rootkit', @@ -3949,9 +3909,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.rundll32Description', - { - defaultMessage: 'Rundll32 (T1085)', - } + { defaultMessage: 'Rundll32 (T1085)' } ), id: 'T1085', name: 'Rundll32', @@ -4050,9 +4008,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.scriptingDescription', - { - defaultMessage: 'Scripting (T1064)', - } + { defaultMessage: 'Scripting (T1064)' } ), id: 'T1064', name: 'Scripting', @@ -4217,9 +4173,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.sourceDescription', - { - defaultMessage: 'Source (T1153)', - } + { defaultMessage: 'Source (T1153)' } ), id: 'T1153', name: 'Source', @@ -4351,9 +4305,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.sudoDescription', - { - defaultMessage: 'Sudo (T1169)', - } + { defaultMessage: 'Sudo (T1169)' } ), id: 'T1169', name: 'Sudo', @@ -4529,9 +4481,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.timestompDescription', - { - defaultMessage: 'Timestomp (T1099)', - } + { defaultMessage: 'Timestomp (T1099)' } ), id: 'T1099', name: 'Timestomp', @@ -4564,9 +4514,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.trapDescription', - { - defaultMessage: 'Trap (T1154)', - } + { defaultMessage: 'Trap (T1154)' } ), id: 'T1154', name: 'Trap', @@ -4698,9 +4646,7 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.webShellDescription', - { - defaultMessage: 'Web Shell (T1100)', - } + { defaultMessage: 'Web Shell (T1100)' } ), id: 'T1100', name: 'Web Shell', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 0cd75506fa9f5..a327f8498f7c0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -300,28 +300,40 @@ describe('rule helpers', () => { }); describe('getHumanizedDuration', () => { - test('returns from as seconds if from duration is less than a minute', () => { + test('returns from as seconds if from duration is specified in seconds', () => { const result = getHumanizedDuration('now-62s', '1m'); expect(result).toEqual('2s'); }); - test('returns from as minutes if from duration is less than an hour', () => { + test('returns from as seconds if from duration is specified in seconds greater than 60', () => { + const result = getHumanizedDuration('now-122s', '1m'); + + expect(result).toEqual('62s'); + }); + + test('returns from as minutes if from duration is specified in minutes', () => { const result = getHumanizedDuration('now-660s', '5m'); expect(result).toEqual('6m'); }); - test('returns from as hours if from duration is more than 60 minutes', () => { - const result = getHumanizedDuration('now-7400s', '5m'); + test('returns from as minutes if from duration is specified in minutes greater than 60', () => { + const result = getHumanizedDuration('now-6600s', '5m'); + + expect(result).toEqual('105m'); + }); + + test('returns from as hours if from duration is specified in hours', () => { + const result = getHumanizedDuration('now-7500s', '5m'); - expect(result).toEqual('1h'); + expect(result).toEqual('2h'); }); test('returns from as if from is not parsable as dateMath', () => { const result = getHumanizedDuration('randomstring', '5m'); - expect(result).toEqual('NaNh'); + expect(result).toEqual('NaNs'); }); test('returns from as 5m if interval is not parsable as dateMath', () => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 4dbcffbc807ec..ffcf25d253798 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -118,15 +118,21 @@ export const getHumanizedDuration = (from: string, interval: string): string => const intervalValue = dateMath.parse(`now-${interval}`) ?? moment(); const fromDuration = moment.duration(intervalValue.diff(fromValue)); - const fromHumanize = `${Math.floor(fromDuration.asHours())}h`; - if (fromDuration.asSeconds() < 60) { - return `${Math.floor(fromDuration.asSeconds())}s`; - } else if (fromDuration.asMinutes() < 60) { - return `${Math.floor(fromDuration.asMinutes())}m`; + // Basing calculations off floored seconds count as moment durations weren't precise + const intervalDuration = Math.floor(fromDuration.asSeconds()); + // For consistency of display value + if (intervalDuration === 0) { + return `0s`; } - return fromHumanize; + if (intervalDuration % 3600 === 0) { + return `${intervalDuration / 3600}h`; + } else if (intervalDuration % 60 === 0) { + return `${intervalDuration / 60}m`; + } else { + return `${intervalDuration}s`; + } }; export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRule => { diff --git a/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js b/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js index 5c31b3fad685a..aa4112d8a6f97 100644 --- a/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js +++ b/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js @@ -13,9 +13,10 @@ const fetch = require('node-fetch'); const { camelCase } = require('lodash'); const { resolve } = require('path'); -const OUTPUT_DIRECTORY = resolve('public', 'pages', 'detection_engine', 'mitre'); -const MITRE_ENTREPRISE_ATTACK_URL = - 'https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json'; +const OUTPUT_DIRECTORY = resolve('public', 'detections', 'mitre'); +// Revert to https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json once we support sub-techniques +const MITRE_ENTERPRISE_ATTACK_URL = + 'https://raw.githubusercontent.com/mitre/cti/ATT%26CK-v6.3/enterprise-attack/enterprise-attack.json'; const getTacticsOptions = (tactics) => tactics.map((t) => @@ -63,7 +64,7 @@ const getIdReference = (references) => ); async function main() { - fetch(MITRE_ENTREPRISE_ATTACK_URL) + fetch(MITRE_ENTERPRISE_ATTACK_URL) .then((res) => res.json()) .then((json) => { const mitreData = json.objects; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx index 2685e62eb9a6c..2ef887d0627cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -74,4 +74,51 @@ describe('EmailActionConnectorFields renders', () => { expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeFalsy(); expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeFalsy(); }); + + test('should display a message to remember username and password when creating a connector with authentication', () => { + const actionConnector = { + actionTypeId: '.email', + config: { + hasAuth: true, + }, + secrets: {}, + } as EmailActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message when editing an authenticated email connector explaining why username and password must be re-entered', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + hasAuth: true, + }, + } as EmailActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx index 1e92e9fc2519c..111f6c9a47da9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx @@ -12,6 +12,7 @@ import { EuiFieldPassword, EuiSwitch, EuiFormRow, + EuiText, EuiTitle, EuiSpacer, EuiCallOut, @@ -56,7 +57,7 @@ export const EmailActionConnectorFields: React.FunctionComponent } @@ -202,17 +203,7 @@ export const EmailActionConnectorFields: React.FunctionComponent {hasAuth ? ( <> - {action.id ? ( - <> - - - - - ) : null} + {getEncryptedFieldNotifyLabel(!action.id)} + + + + + + + ); + } + return ( + + + + + + ); +} + // eslint-disable-next-line import/no-default-export export { EmailActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx index 61923d8f78b51..f476522c2bf5a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -68,7 +68,7 @@ describe('jira connector validation', () => { errors: { apiUrl: ['URL is required.'], email: [], - apiToken: ['API token or Password is required'], + apiToken: ['API token or password is required'], projectKey: ['Project key is required'], }, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index 2cac1819d552d..6d055f4fdb9f2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -96,4 +96,60 @@ describe('JiraActionConnectorFields renders', () => { wrapper.find('[data-test-subj="connector-jira-apiToken-form-input"]').length > 0 ).toBeTruthy(); }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + actionTypeId: '.jira', + isPreconfigured: false, + secrets: {}, + config: {}, + } as JiraActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + email: 'email', + apiToken: 'token', + }, + id: 'test', + actionTypeId: '.jira', + isPreconfigured: false, + name: 'jira', + config: { + apiUrl: 'https://test/', + projectKey: 'CK', + }, + } as JiraActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx index 2ab9843c143b9..35c6bd7af00ab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -6,12 +6,15 @@ import React, { useCallback } from 'react'; import { + EuiCallOut, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiFieldPassword, EuiSpacer, + EuiText, + EuiTitle, } from '@elastic/eui'; import { isEmpty } from 'lodash'; @@ -133,6 +136,20 @@ const JiraConnectorFields: React.FC + + + +

{i18n.JIRA_AUTHENTICATION_LABEL}

+
+
+
+ + + + {getEncryptedFieldNotifyLabel(!action.id)} + + + + {i18n.JIRA_REMEMBER_VALUES_LABEL} + + ); + } + return ( + + ); +} + // eslint-disable-next-line import/no-default-export export { JiraConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts index 019133b03d55f..f6db56e188322 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const JIRA_DESC = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', { - defaultMessage: 'Push or update data to a new issue in Jira', + defaultMessage: 'Create an incident in Jira.', } ); @@ -55,31 +55,54 @@ export const JIRA_PROJECT_KEY_REQUIRED = i18n.translate( } ); +export const JIRA_AUTHENTICATION_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.authenticationLabel', + { + defaultMessage: 'Authentication', + } +); + +export const JIRA_REMEMBER_VALUES_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.rememberValuesLabel', + { + defaultMessage: + 'Remember these values. You must reenter them each time you edit the connector.', + } +); + +export const JIRA_REENTER_VALUES_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.reenterValuesLabel', + { + defaultMessage: + 'Authentication credentials are encrypted. Please reenter values for these fields.', + } +); + export const JIRA_EMAIL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.emailTextFieldLabel', { - defaultMessage: 'Email or Username', + defaultMessage: 'Username or email address', } ); export const JIRA_EMAIL_REQUIRED = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredEmailTextField', { - defaultMessage: 'Email or Username is required', + defaultMessage: 'Username or email address is required', } ); export const JIRA_API_TOKEN_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.apiTokenTextFieldLabel', { - defaultMessage: 'API token or Password', + defaultMessage: 'API token or password', } ); export const JIRA_API_TOKEN_REQUIRED = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredApiTokenTextField', { - defaultMessage: 'API token or Password is required', + defaultMessage: 'API token or password is required', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx index 53e68e6453690..18978be7b4680 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -49,4 +49,56 @@ describe('PagerDutyActionConnectorFields renders', () => { ); expect(wrapper.find('[data-test-subj="pagerdutyRoutingKeyInput"]').length > 0).toBeTruthy(); }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + actionTypeId: '.pagerduty', + secrets: {}, + config: {}, + } as PagerDutyActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as PagerDutyActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx index 6399e1f80984c..ad2d5b3be5268 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; -import { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; +import { EuiCallOut, EuiFieldText, EuiFormRow, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; @@ -53,7 +53,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent } @@ -66,26 +66,61 @@ const PagerDutyActionConnectorFields: React.FunctionComponent - 0 && routingKey !== undefined} - name="routingKey" - readOnly={readOnly} - value={routingKey || ''} - data-test-subj="pagerdutyRoutingKeyInput" - onChange={(e: React.ChangeEvent) => { - editActionSecrets('routingKey', e.target.value); - }} - onBlur={() => { - if (!routingKey) { - editActionSecrets('routingKey', ''); - } - }} - /> + + {getEncryptedFieldNotifyLabel(!action.id)} + 0 && routingKey !== undefined} + name="routingKey" + readOnly={readOnly} + value={routingKey || ''} + data-test-subj="pagerdutyRoutingKeyInput" + onChange={(e: React.ChangeEvent) => { + editActionSecrets('routingKey', e.target.value); + }} + onBlur={() => { + if (!routingKey) { + editActionSecrets('routingKey', ''); + } + }} + /> + ); }; +function getEncryptedFieldNotifyLabel(isCreate: boolean) { + if (isCreate) { + return ( + + + + + + + + ); + } + return ( + + + + + + ); +} + // eslint-disable-next-line import/no-default-export export { PagerDutyActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx index b73eb72f137c1..937fe61e887ea 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx @@ -68,7 +68,7 @@ describe('resilient connector validation', () => { errors: { apiUrl: ['URL is required.'], apiKeyId: [], - apiKeySecret: ['API key secret is required'], + apiKeySecret: ['Secret is required'], orgId: ['Organization ID is required'], }, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index 7e242f1f501d8..6dede85736fcd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -97,4 +97,60 @@ describe('ResilientActionConnectorFields renders', () => { wrapper.find('[data-test-subj="connector-resilient-apiKeySecret-form-input"]').length > 0 ).toBeTruthy(); }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + actionTypeId: '.resilient', + isPreconfigured: false, + config: {}, + secrets: {}, + } as ResilientActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + apiKeyId: 'key', + apiKeySecret: 'secret', + }, + id: 'test', + actionTypeId: '.resilient', + isPreconfigured: false, + name: 'resilient', + config: { + apiUrl: 'https://test/', + orgId: '201', + }, + } as ResilientActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx index 7965e216f1d6c..fe2aa341a7111 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -6,12 +6,15 @@ import React, { useCallback } from 'react'; import { + EuiCallOut, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiFieldPassword, EuiSpacer, + EuiText, + EuiTitle, } from '@elastic/eui'; import { isEmpty } from 'lodash'; @@ -133,6 +136,20 @@ const ResilientConnectorFields: React.FC + + + +

{i18n.API_KEY_LABEL}

+
+
+
+ + + + {getEncryptedFieldNotifyLabel(!action.id)} + + + + {i18n.REMEMBER_VALUES_LABEL} + + ); + } + return ( + + ); +} + // eslint-disable-next-line import/no-default-export export { ResilientConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/translations.ts index 71ad05abfdecf..b45f898d7d809 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/translations.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const DESC = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText', { - defaultMessage: 'Push or update data to a new incident in Resilient.', + defaultMessage: 'Create an incident in IBM Resilient.', } ); @@ -55,31 +55,53 @@ export const ORG_ID_REQUIRED = i18n.translate( } ); +export const API_KEY_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiKey', + { + defaultMessage: 'API key', + } +); + +export const REMEMBER_VALUES_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.rememberValuesLabel', + { + defaultMessage: + 'Remember these values. You must reenter them each time you edit the connector.', + } +); + +export const REENTER_VALUES_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.reenterValuesLabel', + { + defaultMessage: 'ID and secret are encrypted. Please reenter values for these fields.', + } +); + export const API_KEY_ID_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiKeyId', { - defaultMessage: 'API key ID', + defaultMessage: 'ID', } ); export const API_KEY_ID_REQUIRED = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiKeyIdTextField', { - defaultMessage: 'API key ID is required', + defaultMessage: 'ID is required', } ); export const API_KEY_SECRET_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiKeySecret', { - defaultMessage: 'API key secret', + defaultMessage: 'Secret', } ); export const API_KEY_SECRET_REQUIRED = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiKeySecretTextField', { - defaultMessage: 'API key secret is required', + defaultMessage: 'Secret is required', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 216e6967833b2..b666db4024b12 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -83,4 +83,59 @@ describe('ServiceNowActionConnectorFields renders', () => { wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').length > 0 ).toBeTruthy(); }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + actionTypeId: '.servicenow', + isPreconfigured: false, + config: {}, + secrets: {}, + } as ServiceNowActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + isPreconfigured: false, + name: 'servicenow', + config: { + apiUrl: 'https://test/', + }, + } as ServiceNowActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index a8f1ed8d55447..d351b32c3bb06 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -6,6 +6,7 @@ import React, { useCallback } from 'react'; import { + EuiCallOut, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -13,6 +14,8 @@ import { EuiFieldPassword, EuiSpacer, EuiLink, + EuiText, + EuiTitle, } from '@elastic/eui'; import { isEmpty } from 'lodash'; @@ -89,7 +92,7 @@ const ServiceNowConnectorFields: React.FC } @@ -113,6 +116,20 @@ const ServiceNowConnectorFields: React.FC + + + +

{i18n.AUTHENTICATION_LABEL}

+
+
+
+ + + + {getEncryptedFieldNotifyLabel(!action.id)} + + + + {i18n.REMEMBER_VALUES_LABEL} + + ); + } + return ( + + ); +} + // eslint-disable-next-line import/no-default-export export { ServiceNowConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 48544945836d9..67e94bc136cf9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -41,6 +41,28 @@ export const API_URL_INVALID = i18n.translate( } ); +export const AUTHENTICATION_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.authenticationLabel', + { + defaultMessage: 'Authentication', + } +); + +export const REMEMBER_VALUES_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.rememberValuesLabel', + { + defaultMessage: + 'Remember these values. You must reenter them each time you edit the connector.', + } +); + +export const REENTER_VALUES_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel', + { + defaultMessage: 'Username and password are encrypted. Please reenter values for these fields.', + } +); + export const USERNAME_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx index 5bc778830b6e6..f87c2ad99fb4f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx @@ -44,4 +44,54 @@ describe('SlackActionFields renders', () => { 'http:\\test' ); }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + actionTypeId: '.email', + config: {}, + secrets: {}, + } as SlackActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as SlackActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index aa3a1932eacdb..3e744eff07797 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; -import { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; +import { EuiCallOut, EuiFieldText, EuiFormRow, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; @@ -27,7 +27,7 @@ const SlackActionFields: React.FunctionComponent } @@ -40,27 +40,62 @@ const SlackActionFields: React.FunctionComponent - 0 && webhookUrl !== undefined} - name="webhookUrl" - readOnly={readOnly} - placeholder="Example: https://hooks.slack.com/services" - value={webhookUrl || ''} - data-test-subj="slackWebhookUrlInput" - onChange={(e) => { - editActionSecrets('webhookUrl', e.target.value); - }} - onBlur={() => { - if (!webhookUrl) { - editActionSecrets('webhookUrl', ''); - } - }} - /> + + {getEncryptedFieldNotifyLabel(!action.id)} + 0 && webhookUrl !== undefined} + name="webhookUrl" + readOnly={readOnly} + placeholder="Example: https://hooks.slack.com/services" + value={webhookUrl || ''} + data-test-subj="slackWebhookUrlInput" + onChange={(e) => { + editActionSecrets('webhookUrl', e.target.value); + }} + onBlur={() => { + if (!webhookUrl) { + editActionSecrets('webhookUrl', ''); + } + }} + /> + ); }; +function getEncryptedFieldNotifyLabel(isCreate: boolean) { + if (isCreate) { + return ( + + + + + + + + ); + } + return ( + + + + + + ); +} + // eslint-disable-next-line import/no-default-export export { SlackActionFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx index 7f2bed6c41f3b..45e4c566f7a27 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -44,4 +44,55 @@ describe('WebhookActionConnectorFields renders', () => { expect(wrapper.find('[data-test-subj="webhookUserInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="webhookPasswordInput"]').length > 0).toBeTruthy(); }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + secrets: {}, + actionTypeId: '.webhook', + isPreconfigured: false, + config: {}, + } as WebhookActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + isPreconfigured: false, + name: 'webhook', + config: { + method: 'PUT', + url: 'http:\\test', + headers: { 'content-type': 'text' }, + }, + } as WebhookActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx index 52160441adb5b..e4f5ef023a529 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx @@ -7,6 +7,7 @@ import React, { Fragment, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiCallOut, EuiFieldPassword, EuiFieldText, EuiFormRow, @@ -18,6 +19,7 @@ import { EuiDescriptionList, EuiDescriptionListDescription, EuiDescriptionListTitle, + EuiText, EuiTitle, EuiSwitch, EuiButtonEmpty, @@ -266,6 +268,26 @@ const WebhookActionConnectorFields: React.FunctionComponent + + + + +

+ +

+
+
+
+ + + + {getEncryptedFieldNotifyLabel(!action.id)} + + + + + + ); + } + return ( + + ); +} + // eslint-disable-next-line import/no-default-export export { WebhookActionConnectorFields as default }; diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 6f7d693baac7f..297eb2e9b4540 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -24,6 +24,8 @@ { "path": "../plugins/global_search/tsconfig.json" }, { "path": "../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../src/plugins/telemetry_collection_manager/tsconfig.json" }, - { "path": "../../src/plugins/telemetry/tsconfig.json" } + { "path": "../../src/plugins/telemetry/tsconfig.json" }, + { "path": "../../src/plugins/kibana_usage_collection/tsconfig.json" }, + { "path": "../../src/plugins/newsfeed/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 1d392890b27fd..79309369386cf 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -13,10 +13,7 @@ "plugins/apm/e2e/cypress/**/*", "plugins/apm/scripts/**/*", "plugins/licensing/**/*", - "plugins/global_search/**/*", - "../src/plugins/usage_collection/**/*", - "../src/plugins/telemetry_collection_manager/**/*", - "../src/plugins/telemetry/**/*" + "plugins/global_search/**/*" ], "compilerOptions": { "paths": { @@ -36,6 +33,8 @@ { "path": "./plugins/global_search/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, { "path": "../src/plugins/telemetry_collection_manager/tsconfig.json" }, - { "path": "../src/plugins/telemetry/tsconfig.json" } + { "path": "../src/plugins/telemetry/tsconfig.json" }, + { "path": "../src/plugins/kibana_usage_collection/tsconfig.json" }, + { "path": "../src/plugins/newsfeed/tsconfig.json" }, ] } diff --git a/yarn.lock b/yarn.lock index 44c59d0264ddf..e2f5ed412a14a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1244,13 +1244,14 @@ utility-types "^3.10.0" uuid "^3.3.2" -"@elastic/elasticsearch@7.9.1": - version "7.9.1" - resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.9.1.tgz#40f1c38e8f70d783851c13be78a7cb346891c15e" - integrity sha512-NfPADbm9tRK/4ohpm9+aBtJ8WPKQqQaReyBKT225pi2oKQO1IzRlfM+OPplAvbhoH1efrSj1NKk27L+4BCrzXQ== +"@elastic/elasticsearch@7.10.0-rc.1": + version "7.10.0-rc.1" + resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.10.0-rc.1.tgz#c23fc5cbfdb40cf2ce6f9cd796b75940e8c9dc8a" + integrity sha512-STaBlEwYbT8yT3HJ+mbO1kx+Kb7Ft7Q0xG5GxZbqbAJ7PZvgGgJWwN7jUg4oKJHbTfxV3lPvFa+PaUK2TqGuYg== dependencies: debug "^4.1.1" decompress-response "^4.2.0" + hpagent "^0.1.1" ms "^2.1.1" pump "^3.0.0" secure-json-parse "^2.1.0" @@ -4584,6 +4585,21 @@ resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== +"@types/pdfkit@*": + version "0.10.6" + resolved "https://registry.yarnpkg.com/@types/pdfkit/-/pdfkit-0.10.6.tgz#9ddde7e642e6c3f1245134456a03fbc4b67dc4b5" + integrity sha512-o6R2fO/fhg392YNYahiaNGZ8CNdSKMmf5W0LvyjJ/63mLQEPTgl6M9vb4zKoHaGpsP43VimuBz+xgVevsPy8jA== + dependencies: + "@types/node" "*" + +"@types/pdfmake@^0.1.15": + version "0.1.15" + resolved "https://registry.yarnpkg.com/@types/pdfmake/-/pdfmake-0.1.15.tgz#383a8fca407612a580b82d1ca496d39001aee102" + integrity sha512-uyKefZzC1OUTKoUdY0fU9n7BjciSSYPHq2KLQmGNejeZn6Xo6hI04xhAGk368Rv9wHjKo36IGLIVIysvhrGVJQ== + dependencies: + "@types/node" "*" + "@types/pdfkit" "*" + "@types/pegjs@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@types/pegjs/-/pegjs-0.10.1.tgz#9a2f3961dc62430fdb21061eb0ddbd890f9e3b94" @@ -15100,6 +15116,11 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" +hpagent@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/hpagent/-/hpagent-0.1.1.tgz#66f67f16e5c7a8b59a068e40c2658c2c749ad5e2" + integrity sha512-IxJWQiY0vmEjetHdoE9HZjD4Cx+mYTr25tR7JCxXaiI3QxW0YqYyM11KyZbHufoa/piWhMb2+D3FGpMgmA2cFQ== + html-element-map@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/html-element-map/-/html-element-map-1.2.0.tgz#dfbb09efe882806af63d990cf6db37993f099f22"