diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md index 3a5e84ffdc372..268dcdd77d6b4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md @@ -11,8 +11,9 @@ core: { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md index 5300c85cf9406..54d85910f823c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exporter: ISavedObjectsExporter;
importer: ISavedObjectsImporter;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | +| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract;
getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter;
getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | diff --git a/src/core/server/core_route_handler_context.ts b/src/core/server/core_route_handler_context.ts index f3e06ad8f1daa..f5123a91e7100 100644 --- a/src/core/server/core_route_handler_context.ts +++ b/src/core/server/core_route_handler_context.ts @@ -13,8 +13,7 @@ import { SavedObjectsClientContract } from './saved_objects/types'; import { InternalSavedObjectsServiceStart, ISavedObjectTypeRegistry, - ISavedObjectsExporter, - ISavedObjectsImporter, + SavedObjectsClientProviderOptions, } from './saved_objects'; import { InternalElasticsearchServiceStart, @@ -58,8 +57,6 @@ class CoreSavedObjectsRouteHandlerContext { ) {} #scopedSavedObjectsClient?: SavedObjectsClientContract; #typeRegistry?: ISavedObjectTypeRegistry; - #exporter?: ISavedObjectsExporter; - #importer?: ISavedObjectsImporter; public get client() { if (this.#scopedSavedObjectsClient == null) { @@ -75,19 +72,18 @@ class CoreSavedObjectsRouteHandlerContext { return this.#typeRegistry; } - public get exporter() { - if (this.#exporter == null) { - this.#exporter = this.savedObjectsStart.createExporter(this.client); - } - return this.#exporter; - } + public getClient = (options?: SavedObjectsClientProviderOptions) => { + if (!options) return this.client; + return this.savedObjectsStart.getScopedClient(this.request, options); + }; - public get importer() { - if (this.#importer == null) { - this.#importer = this.savedObjectsStart.createImporter(this.client); - } - return this.#importer; - } + public getExporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createExporter(client); + }; + + public getImporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createImporter(client); + }; } class CoreUiSettingsRouteHandlerContext { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index dac2d210eb395..8e4cdc7d59e32 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -49,6 +49,7 @@ import { SavedObjectsServiceStart, ISavedObjectsExporter, ISavedObjectsImporter, + SavedObjectsClientProviderOptions, } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; @@ -415,8 +416,9 @@ export interface RequestHandlerContext { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index a4ce94b177612..19056ae1b9bc7 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -196,8 +196,9 @@ function createCoreRequestHandlerContextMock() { savedObjects: { client: savedObjectsClientMock.create(), typeRegistry: savedObjectsTypeRegistryMock.create(), - exporter: savedObjectsServiceMock.createExporter(), - importer: savedObjectsServiceMock.createImporter(), + getClient: savedObjectsClientMock.create, + getExporter: savedObjectsServiceMock.createExporter, + getImporter: savedObjectsServiceMock.createImporter, }, elasticsearch: { client: elasticsearchServiceMock.createScopedClusterClient(), diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index 609ce2692c777..fe08acf23fd23 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -32,11 +32,13 @@ export const registerDeleteRoute = (router: IRouter, { coreUsageData }: RouteDep catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { force } = req.query; + const { getClient } = context.core.savedObjects; const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsDelete({ request: req }).catch(() => {}); - const result = await context.core.savedObjects.client.delete(type, id, { force }); + const client = getClient(); + const result = await client.delete(type, id, { force }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index fa5517303f18f..e0293a4522fc1 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -165,9 +165,9 @@ export const registerExportRoute = ( }, catchAndReturnBoomErrors(async (context, req, res) => { const cleaned = cleanOptions(req.body); - const supportedTypes = context.core.savedObjects.typeRegistry - .getImportableAndExportableTypes() - .map((t) => t.name); + const { typeRegistry, getExporter, getClient } = context.core.savedObjects; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((t) => t.name); + let options: EitherExportOptions; try { options = validateOptions(cleaned, { @@ -181,7 +181,12 @@ export const registerExportRoute = ( }); } - const exporter = context.core.savedObjects.exporter; + const includedHiddenTypes = supportedTypes.filter((supportedType) => + typeRegistry.isHidden(supportedType) + ); + + const client = getClient({ includedHiddenTypes }); + const exporter = getExporter(client); const usageStatsClient = coreUsageData.getClient(); usageStatsClient diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index e84c638d3ec99..6f75bcf9fd5bf 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -63,6 +63,7 @@ export const registerImportRoute = ( }, catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite, createNewCopies } = req.query; + const { getClient, getImporter, typeRegistry } = context.core.savedObjects; const usageStatsClient = coreUsageData.getClient(); usageStatsClient @@ -84,7 +85,15 @@ export const registerImportRoute = ( }); } - const { importer } = context.core.savedObjects; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((t) => t.name); + + const includedHiddenTypes = supportedTypes.filter((supportedType) => + typeRegistry.isHidden(supportedType) + ); + + const client = getClient({ includedHiddenTypes }); + const importer = getImporter(client); + try { const result = await importer.import({ readStream, diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index 7b7a71b7ca858..eaec6e16cbd8c 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -26,7 +26,8 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); - savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient = handlerContext.savedObjects.getClient(); + handlerContext.savedObjects.getClient = jest.fn().mockImplementation(() => savedObjectsClient); const router = httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index 40f13064b53f0..09d475f29f362 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -40,9 +40,13 @@ describe('POST /api/saved_objects/_export', () => { handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); - exporter = handlerContext.savedObjects.exporter; + exporter = handlerContext.savedObjects.getExporter(); const router = httpSetup.createRouter('/api/saved_objects/'); + handlerContext.savedObjects.getExporter = jest + .fn() + .mockImplementation(() => exporter as ReturnType); + coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsExport.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); @@ -77,6 +81,7 @@ describe('POST /api/saved_objects/_export', () => { ], }, ]; + exporter.exportByTypes.mockResolvedValueOnce(createListStream(sortedObjects)); const result = await supertest(httpSetup.server.listener) diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 24122c61c9f42..be4d2160a967b 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -68,9 +68,9 @@ describe(`POST ${URL}`, () => { typeRegistry: handlerContext.savedObjects.typeRegistry, importSizeLimit: 10000, }); - handlerContext.savedObjects.importer.import.mockImplementation((options) => - importer.import(options) - ); + handlerContext.savedObjects.getImporter = jest + .fn() + .mockImplementation(() => importer as jest.Mocked); const router = httpSetup.createRouter('/internal/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index b23211aef092f..d84b56156b543 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -66,7 +66,7 @@ describe(`POST ${URL}`, () => { } as any) ); - savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient = handlerContext.savedObjects.getClient(); savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const importer = new SavedObjectsImporter({ @@ -74,9 +74,10 @@ describe(`POST ${URL}`, () => { typeRegistry: handlerContext.savedObjects.typeRegistry, importSizeLimit: 10000, }); - handlerContext.savedObjects.importer.resolveImportErrors.mockImplementation((options) => - importer.resolveImportErrors(options) - ); + + handlerContext.savedObjects.getImporter = jest + .fn() + .mockImplementation(() => importer as jest.Mocked); const router = httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 2a664328d4df2..a05c7d30b91fd 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -9,6 +9,7 @@ import { extname } from 'path'; import { Readable } from 'stream'; import { schema } from '@kbn/config-schema'; +import { chain } from 'lodash'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; @@ -91,7 +92,18 @@ export const registerResolveImportErrorsRoute = ( }); } - const { importer } = context.core.savedObjects; + const { getClient, getImporter, typeRegistry } = context.core.savedObjects; + + const includedHiddenTypes = chain(req.body.retries) + .map('type') + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); + const importer = getImporter(client); try { const result = await importer.resolveImportErrors({ diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 34df3bcf85324..377cd2bc2068a 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1924,8 +1924,9 @@ export interface RequestHandlerContext { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts index 0c4f312126762..fc8911a251773 100644 --- a/src/dev/typescript/build_ts_refs_cli.ts +++ b/src/dev/typescript/build_ts_refs_cli.ts @@ -23,13 +23,18 @@ export async function runBuildRefsCli() { async ({ log, flags }) => { const outDirs = getOutputsDeep(REF_CONFIG_PATHS); - if (flags.clean) { + const cacheEnabled = process.env.BUILD_TS_REFS_CACHE_ENABLE === 'true' || !!flags.cache; + const doCapture = process.env.BUILD_TS_REFS_CACHE_CAPTURE === 'true'; + const doClean = !!flags.clean || doCapture; + const doInitCache = cacheEnabled && !doClean; + + if (doClean) { log.info('deleting', outDirs.length, 'ts output directories'); await concurrentMap(100, outDirs, (outDir) => del(outDir)); } let outputCache; - if (flags.cache) { + if (cacheEnabled) { outputCache = await RefOutputCache.create({ log, outDirs, @@ -37,17 +42,19 @@ export async function runBuildRefsCli() { workingDir: CACHE_WORKING_DIR, upstreamUrl: 'https://github.com/elastic/kibana.git', }); + } + if (outputCache && doInitCache) { await outputCache.initCaches(); } await buildAllTsRefs(log); - if (outputCache) { - if (process.env.BUILD_TS_REFS_CACHE_CAPTURE === 'true') { - await outputCache.captureCache(Path.resolve(REPO_ROOT, 'target/ts_refs_cache')); - } + if (outputCache && doCapture) { + await outputCache.captureCache(Path.resolve(REPO_ROOT, 'target/ts_refs_cache')); + } + if (outputCache) { await outputCache.cleanup(); } }, @@ -55,9 +62,6 @@ export async function runBuildRefsCli() { description: 'Build TypeScript projects', flags: { boolean: ['clean', 'cache'], - default: { - cache: process.env.BUILD_TS_REFS_CACHE_ENABLE === 'true' ? true : false, - }, }, log: { defaultLevel: 'debug', diff --git a/src/dev/typescript/ref_output_cache/repo_info.ts b/src/dev/typescript/ref_output_cache/repo_info.ts index 5ca792332bafa..9a51f3f75182b 100644 --- a/src/dev/typescript/ref_output_cache/repo_info.ts +++ b/src/dev/typescript/ref_output_cache/repo_info.ts @@ -31,7 +31,7 @@ export class RepoInfo { this.log.info('determining merge base with upstream'); - const mergeBase = this.git(['merge-base', ref, 'FETCH_HEAD']); + const mergeBase = await this.git(['merge-base', ref, 'FETCH_HEAD']); this.log.info('merge base with', upstreamBranch, 'is', mergeBase); return mergeBase; diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts index d991c7ad23bc8..7a91acb7e5a38 100644 --- a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts @@ -11,6 +11,7 @@ import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES, TimeRange, TimeRangeBounds, UI_SETTINGS } from '../../../../common'; +import { IFieldType } from '../../../index_patterns'; import { intervalOptions, autoInterval, isAutoInterval } from './_interval_options'; import { createFilterDateHistogram } from './create_filter/date_histogram'; @@ -58,7 +59,7 @@ export function isDateHistogramBucketAggConfig(agg: any): agg is IBucketDateHist } export interface AggParamsDateHistogram extends BaseAggParams { - field?: string; + field?: IFieldType | string; timeRange?: TimeRange; useNormalizedEsInterval?: boolean; scaleMetricValues?: boolean; diff --git a/src/plugins/data/common/search/aggs/utils/infer_time_zone.test.ts b/src/plugins/data/common/search/aggs/utils/infer_time_zone.test.ts index 5547f554299ce..0c94769f0538e 100644 --- a/src/plugins/data/common/search/aggs/utils/infer_time_zone.test.ts +++ b/src/plugins/data/common/search/aggs/utils/infer_time_zone.test.ts @@ -18,7 +18,7 @@ jest.mock('moment', () => { return moment; }); -import { IndexPattern } from '../../../index_patterns'; +import { IndexPattern, IndexPatternField } from '../../../index_patterns'; import { AggParamsDateHistogram } from '../buckets'; import { inferTimeZone } from './infer_time_zone'; @@ -51,6 +51,31 @@ describe('inferTimeZone', () => { ).toEqual('UTC'); }); + it('reads time zone from index pattern type meta if available when the field is not a string', () => { + expect( + inferTimeZone( + { + field: { + name: 'mydatefield', + } as IndexPatternField, + }, + ({ + typeMeta: { + aggs: { + date_histogram: { + mydatefield: { + time_zone: 'UTC', + }, + }, + }, + }, + } as unknown) as IndexPattern, + () => false, + jest.fn() + ) + ).toEqual('UTC'); + }); + it('reads time zone from moment if set to default', () => { expect(inferTimeZone({}, {} as IndexPattern, () => true, jest.fn())).toEqual('CET'); }); diff --git a/src/plugins/data/common/search/aggs/utils/infer_time_zone.ts b/src/plugins/data/common/search/aggs/utils/infer_time_zone.ts index a997a601e940b..b031fb890b77c 100644 --- a/src/plugins/data/common/search/aggs/utils/infer_time_zone.ts +++ b/src/plugins/data/common/search/aggs/utils/infer_time_zone.ts @@ -20,7 +20,8 @@ export function inferTimeZone( if (!tz && params.field) { // If a field has been configured check the index pattern's typeMeta if a date_histogram on that // field requires a specific time_zone - tz = indexPattern.typeMeta?.aggs?.date_histogram?.[params.field]?.time_zone; + const fieldName = typeof params.field === 'string' ? params.field : params.field.name; + tz = indexPattern.typeMeta?.aggs?.date_histogram?.[fieldName]?.time_zone; } if (!tz) { // If the index pattern typeMeta data, didn't had a time zone assigned for the selected field use the configured tz diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 4c64a117f8cfe..9e889e85734ee 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -28,6 +28,7 @@ const mockSource2 = { excludes: ['bar-*'] }; const indexPattern = ({ title: 'foo', + fields: [{ name: 'foo-bar' }, { name: 'field1' }, { name: 'field2' }], getComputedFields, getSourceFiltering: () => mockSource, } as unknown) as IndexPattern; @@ -51,6 +52,11 @@ describe('SearchSource', () => { let searchSource: SearchSource; beforeEach(() => { + const getConfigMock = jest + .fn() + .mockImplementation((param) => param === 'metaFields' && ['_type', '_source']) + .mockName('getConfig'); + mockSearchMethod = jest .fn() .mockReturnValue( @@ -61,7 +67,7 @@ describe('SearchSource', () => { ); searchSourceDependencies = { - getConfig: jest.fn(), + getConfig: getConfigMock, search: mockSearchMethod, onResponse: (req, res) => res, legacy: { @@ -518,6 +524,54 @@ describe('SearchSource', () => { expect(request.script_fields).toEqual({ hello: {} }); }); + test('request all fields except the ones specified with source filters', async () => { + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: [], + docvalueFields: [], + }), + } as unknown) as IndexPattern); + searchSource.setField('fields', ['hello', 'foo']); + + const request = await searchSource.getSearchRequestBody(); + expect(request.fields).toEqual(['hello']); + }); + + test('request all fields from index pattern except the ones specified with source filters', async () => { + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: [], + docvalueFields: [], + }), + } as unknown) as IndexPattern); + searchSource.setField('fields', ['*']); + + const request = await searchSource.getSearchRequestBody(); + expect(request.fields).toEqual([{ field: 'field1' }, { field: 'field2' }]); + }); + + test('request all fields from index pattern except the ones specified with source filters with unmapped_fields option', async () => { + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: [], + docvalueFields: [], + }), + } as unknown) as IndexPattern); + searchSource.setField('fields', [{ field: '*', include_unmapped: 'true' }]); + + const request = await searchSource.getSearchRequestBody(); + expect(request.fields).toEqual([ + { field: 'field1', include_unmapped: 'true' }, + { field: 'field2', include_unmapped: 'true' }, + ]); + }); + test('returns all scripted fields when one fields entry is *', async () => { searchSource.setField('index', ({ ...indexPattern, @@ -836,5 +890,25 @@ describe('SearchSource', () => { expect(references[1].type).toEqual('index-pattern'); expect(JSON.parse(searchSourceJSON).filter[0].meta.indexRefName).toEqual(references[1].name); }); + + test('mvt geoshape layer test', async () => { + // @ts-expect-error TS won't like using this field name, but technically it's possible. + searchSource.setField('docvalue_fields', ['prop1']); + searchSource.setField('source', ['geometry']); + searchSource.setField('fieldsFromSource', ['geometry', 'prop1']); + searchSource.setField('index', ({ + ...indexPattern, + getSourceFiltering: () => ({ excludes: [] }), + getComputedFields: () => ({ + storedFields: ['*'], + scriptFields: {}, + docvalueFields: [], + }), + } as unknown) as IndexPattern); + const request = await searchSource.getSearchRequestBody(); + expect(request.stored_fields).toEqual(['geometry', 'prop1']); + expect(request.docvalue_fields).toEqual(['prop1']); + expect(request._source).toEqual(['geometry']); + }); }); }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 1c1360414cb2e..8580fb7910735 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -59,12 +59,13 @@ */ import { setWith } from '@elastic/safer-lodash-set'; -import { uniqueId, keyBy, pick, difference, omit, isObject, isFunction } from 'lodash'; +import { uniqueId, keyBy, pick, difference, omit, isFunction, isEqual } from 'lodash'; import { map, switchMap, tap } from 'rxjs/operators'; import { defer, from } from 'rxjs'; +import { isObject } from 'rxjs/internal-compatibility'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; -import { IIndexPattern } from '../../index_patterns'; +import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patterns'; import { ISearchGeneric, ISearchOptions } from '../..'; import type { ISearchSource, @@ -500,10 +501,53 @@ export class SearchSource { } } + private readonly getFieldName = (fld: string | Record): string => + typeof fld === 'string' ? fld : fld.field; + + private getFieldsWithoutSourceFilters( + index: IndexPattern | undefined, + bodyFields: SearchFieldValue[] + ) { + if (!index) { + return bodyFields; + } + const { fields } = index; + const sourceFilters = index.getSourceFiltering(); + if (!sourceFilters || sourceFilters.excludes?.length === 0 || bodyFields.length === 0) { + return bodyFields; + } + const metaFields = this.dependencies.getConfig(UI_SETTINGS.META_FIELDS); + const sourceFiltersValues = sourceFilters.excludes; + const wildcardField = bodyFields.find( + (el: SearchFieldValue) => el === '*' || (el as Record).field === '*' + ); + const filterSourceFields = (fieldName: string) => { + return ( + fieldName && + !sourceFiltersValues.some((sourceFilter) => fieldName.match(sourceFilter)) && + !metaFields.includes(fieldName) + ); + }; + if (!wildcardField) { + // we already have an explicit list of fields, so we just remove source filters from that list + return bodyFields.filter((fld: SearchFieldValue) => + filterSourceFields(this.getFieldName(fld)) + ); + } + // we need to get the list of fields from an index pattern + return fields + .filter((fld: IndexPatternField) => filterSourceFields(fld.name)) + .map((fld: IndexPatternField) => ({ + field: fld.name, + ...((wildcardField as Record)?.include_unmapped && { + include_unmapped: (wildcardField as Record).include_unmapped, + }), + })); + } + private flatten() { const { getConfig } = this.dependencies; const searchRequest = this.mergeProps(); - searchRequest.body = searchRequest.body || {}; const { body, index, query, filters, highlightAll } = searchRequest; searchRequest.indexType = this.getIndexType(index); @@ -517,10 +561,7 @@ export class SearchSource { storedFields: ['*'], runtimeFields: {}, }; - const fieldListProvided = !!body.fields; - const getFieldName = (fld: string | Record): string => - typeof fld === 'string' ? fld : fld.field; // set defaults let fieldsFromSource = searchRequest.fieldsFromSource || []; @@ -539,26 +580,22 @@ export class SearchSource { if (!body.hasOwnProperty('_source')) { body._source = sourceFilters; } - if (body._source.excludes) { - const filter = fieldWildcardFilter( - body._source.excludes, - getConfig(UI_SETTINGS.META_FIELDS) - ); - // also apply filters to provided fields & default docvalueFields - body.fields = body.fields.filter((fld: SearchFieldValue) => filter(getFieldName(fld))); - fieldsFromSource = fieldsFromSource.filter((fld: SearchFieldValue) => - filter(getFieldName(fld)) - ); - filteredDocvalueFields = filteredDocvalueFields.filter((fld: SearchFieldValue) => - filter(getFieldName(fld)) - ); - } + + const filter = fieldWildcardFilter(body._source.excludes, getConfig(UI_SETTINGS.META_FIELDS)); + // also apply filters to provided fields & default docvalueFields + body.fields = body.fields.filter((fld: SearchFieldValue) => filter(this.getFieldName(fld))); + fieldsFromSource = fieldsFromSource.filter((fld: SearchFieldValue) => + filter(this.getFieldName(fld)) + ); + filteredDocvalueFields = filteredDocvalueFields.filter((fld: SearchFieldValue) => + filter(this.getFieldName(fld)) + ); } // specific fields were provided, so we need to exclude any others if (fieldListProvided || fieldsFromSource.length) { const bodyFieldNames = body.fields.map((field: string | Record) => - getFieldName(field) + this.getFieldName(field) ); const uniqFieldNames = [...new Set([...bodyFieldNames, ...fieldsFromSource])]; @@ -579,17 +616,20 @@ export class SearchSource { const remainingFields = difference(uniqFieldNames, [ ...Object.keys(body.script_fields), ...Object.keys(body.runtime_mappings), - ]).filter(Boolean); + ]).filter((remainingField) => { + if (!remainingField) return false; + if (!body._source || !body._source.excludes) return true; + return !body._source.excludes.includes(remainingField); + }); - // only include unique values body.stored_fields = [...new Set(remainingFields)]; - + // only include unique values if (fieldsFromSource.length) { - // include remaining fields in _source - setWith(body, '_source.includes', remainingFields, (nsValue) => - isObject(nsValue) ? {} : nsValue - ); - + if (!isEqual(remainingFields, fieldsFromSource)) { + setWith(body, '_source.includes', remainingFields, (nsValue) => + isObject(nsValue) ? {} : nsValue + ); + } // if items that are in the docvalueFields are provided, we should // make sure those are added to the fields API unless they are // already set in docvalue_fields @@ -597,10 +637,10 @@ export class SearchSource { ...body.fields, ...filteredDocvalueFields.filter((fld: SearchFieldValue) => { return ( - fieldsFromSource.includes(getFieldName(fld)) && + fieldsFromSource.includes(this.getFieldName(fld)) && !(body.docvalue_fields || []) - .map((d: string | Record) => getFieldName(d)) - .includes(getFieldName(fld)) + .map((d: string | Record) => this.getFieldName(d)) + .includes(this.getFieldName(fld)) ); }), ]; @@ -614,20 +654,22 @@ export class SearchSource { // if items that are in the docvalueFields are provided, we should // inject the format from the computed fields if one isn't given const docvaluesIndex = keyBy(filteredDocvalueFields, 'field'); - body.fields = body.fields.map((fld: SearchFieldValue) => { - const fieldName = getFieldName(fld); - if (Object.keys(docvaluesIndex).includes(fieldName)) { - // either provide the field object from computed docvalues, - // or merge the user-provided field with the one in docvalues - return typeof fld === 'string' - ? docvaluesIndex[fld] - : { - ...docvaluesIndex[fieldName], - ...fld, - }; + body.fields = this.getFieldsWithoutSourceFilters(index, body.fields).map( + (fld: SearchFieldValue) => { + const fieldName = this.getFieldName(fld); + if (Object.keys(docvaluesIndex).includes(fieldName)) { + // either provide the field object from computed docvalues, + // or merge the user-provided field with the one in docvalues + return typeof fld === 'string' + ? docvaluesIndex[fld] + : { + ...docvaluesIndex[fieldName], + ...fld, + }; + } + return fld; } - return fld; - }); + ); } } else { body.fields = filteredDocvalueFields; diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index 684a7d4fd467c..093b445267241 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { DocViewTableRow } from './table_row'; import { trimAngularSpan } from './table_helper'; @@ -54,6 +54,20 @@ export function DocViewTable({ setFieldsWithParents(arr); }, [indexPattern, hit]); + const toggleColumn = useCallback( + (field: string) => { + if (!onRemoveColumn || !onAddColumn || !columns) { + return; + } + if (columns.includes(field)) { + onRemoveColumn(field); + } else { + onAddColumn(field); + } + }, + [onRemoveColumn, onAddColumn, columns] + ); + if (!indexPattern) { return null; } @@ -65,6 +79,7 @@ export function DocViewTable({ fieldRowOpen[field] = !fieldRowOpen[field]; setFieldRowOpen({ ...fieldRowOpen }); } + return ( @@ -85,16 +100,6 @@ export function DocViewTable({ const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; const isCollapsed = isCollapsible && !fieldRowOpen[field]; - const toggleColumn = - onRemoveColumn && onAddColumn && Array.isArray(columns) - ? () => { - if (columns.includes(field)) { - onRemoveColumn(field); - } else { - onAddColumn(field); - } - } - : undefined; const displayUnderscoreWarning = !mapping(field) && field.indexOf('_') === 0; const fieldType = isNestedFieldParent(field, indexPattern) @@ -109,10 +114,10 @@ export function DocViewTable({ displayUnderscoreWarning={displayUnderscoreWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} - isColumnActive={Array.isArray(columns) && columns.includes(field)} + isColumnActive={!!columns?.includes(field)} onFilter={filter} onToggleCollapse={() => toggleValueCollapse(field)} - onToggleColumn={toggleColumn} + onToggleColumn={() => toggleColumn(field)} value={value} valueRaw={valueRaw} /> @@ -123,7 +128,7 @@ export function DocViewTable({ data-test-subj={`tableDocViewRow-multifieldsTitle-${field}`} > -
  + {i18n.translate('discover.fieldChooser.discoverField.multiFields', { defaultMessage: 'Multi fields', @@ -142,10 +147,12 @@ export function DocViewTable({ displayUnderscoreWarning={displayUnderscoreWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} - isColumnActive={Array.isArray(columns) && columns.includes(field)} + isColumnActive={Array.isArray(columns) && columns.includes(multiField)} onFilter={filter} - onToggleCollapse={() => toggleValueCollapse(field)} - onToggleColumn={toggleColumn} + onToggleCollapse={() => { + toggleValueCollapse(multiField); + }} + onToggleColumn={() => toggleColumn(multiField)} value={value} valueRaw={valueRaw} /> diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz index d47426df12ddf..0ab2b67c72dba 100644 Binary files a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz and b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz differ diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index d32a854f2bc4b..7c00a46602e26 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -143,7 +143,15 @@ export function createInstallRoute( let createResults; try { - createResults = await context.core.savedObjects.client.bulkCreate( + const { getClient, typeRegistry } = context.core.savedObjects; + + const includedHiddenTypes = sampleDataset.savedObjects + .map((object) => object.type) + .filter((supportedType) => typeRegistry.isHidden(supportedType)); + + const client = getClient({ includedHiddenTypes }); + + createResults = await client.bulkCreate( sampleDataset.savedObjects.map(({ version, ...savedObject }) => savedObject), { overwrite: true } ); @@ -156,8 +164,8 @@ export function createInstallRoute( return Boolean(savedObjectCreateResult.error); }); if (errors.length > 0) { - const errMsg = `sample_data install errors while loading saved objects. Errors: ${errors.join( - ',' + const errMsg = `sample_data install errors while loading saved objects. Errors: ${JSON.stringify( + errors )}`; logger.warn(errMsg); return res.customError({ body: errMsg, statusCode: 403 }); diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index 54e6fa0936abc..aa8ed67cf840a 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -33,7 +33,7 @@ export function createUninstallRoute( client: { callAsCurrentUser }, }, }, - savedObjects: { client: savedObjectsClient }, + savedObjects: { getClient: getSavedObjectsClient, typeRegistry }, }, }, request, @@ -61,6 +61,12 @@ export function createUninstallRoute( } } + const includedHiddenTypes = sampleDataset.savedObjects + .map((object) => object.type) + .filter((supportedType) => typeRegistry.isHidden(supportedType)); + + const savedObjectsClient = getSavedObjectsClient({ includedHiddenTypes }); + const deletePromises = sampleDataset.savedObjects.map(({ type, id }) => savedObjectsClient.delete(type, id) ); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx index b1570bb1fff0d..8b07351f6c2c2 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx @@ -123,7 +123,10 @@ const CountIndicators: FC<{ importItems: ImportItem[] }> = ({ importItems }) => {errorCount && ( -

+

{ + const { query } = req; const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; - const searchTypes = Array.isArray(req.query.type) ? req.query.type : [req.query.type]; - const includedFields = Array.isArray(req.query.fields) - ? req.query.fields - : [req.query.fields]; + const { getClient, typeRegistry } = context.core.savedObjects; + + const searchTypes = Array.isArray(query.type) ? query.type : [query.type]; + const includedFields = Array.isArray(query.fields) ? query.fields : [query.fields]; + const importAndExportableTypes = searchTypes.filter((type) => - managementService.isImportAndExportable(type) + typeRegistry.isImportableAndExportable(type) ); + const includedHiddenTypes = importAndExportableTypes.filter((type) => + typeRegistry.isHidden(type) + ); + + const client = getClient({ includedHiddenTypes }); const searchFields = new Set(); + importAndExportableTypes.forEach((type) => { const searchField = managementService.getDefaultSearchField(type); if (searchField) { @@ -64,7 +71,7 @@ export const registerFindRoute = ( }); const findResponse = await client.find({ - ...req.query, + ...query, fields: undefined, searchFields: [...searchFields], }); diff --git a/src/plugins/saved_objects_management/server/routes/get.ts b/src/plugins/saved_objects_management/server/routes/get.ts index 1e0115db9e43c..5a48f2f2affa7 100644 --- a/src/plugins/saved_objects_management/server/routes/get.ts +++ b/src/plugins/saved_objects_management/server/routes/get.ts @@ -26,10 +26,14 @@ export const registerGetRoute = ( }, }, router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; + const includedHiddenTypes = [type].filter( + (entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry) + ); - const { type, id } = req.params; + const client = getClient({ includedHiddenTypes }); const findResponse = await client.get(type, id); const enhancedSavedObject = injectMetaAttributes(findResponse, managementService); diff --git a/src/plugins/saved_objects_management/server/routes/relationships.ts b/src/plugins/saved_objects_management/server/routes/relationships.ts index 5e30f49cde67f..9e2c2031d8abd 100644 --- a/src/plugins/saved_objects_management/server/routes/relationships.ts +++ b/src/plugins/saved_objects_management/server/routes/relationships.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; +import { chain } from 'lodash'; import { findRelationships } from '../lib'; import { ISavedObjectsManagement } from '../services'; @@ -31,12 +32,21 @@ export const registerRelationshipsRoute = ( }, router.handleLegacyErrors(async (context, req, res) => { const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; const { type, id } = req.params; - const { size } = req.query; - const savedObjectTypes = Array.isArray(req.query.savedObjectTypes) - ? req.query.savedObjectTypes - : [req.query.savedObjectTypes]; + const { size, savedObjectTypes: maybeArraySavedObjectTypes } = req.query; + const savedObjectTypes = Array.isArray(maybeArraySavedObjectTypes) + ? maybeArraySavedObjectTypes + : [maybeArraySavedObjectTypes]; + + const includedHiddenTypes = chain(maybeArraySavedObjectTypes) + .uniq() + .filter( + (entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); const findRelationsResponse = await findRelationships({ type, diff --git a/src/plugins/saved_objects_management/server/routes/scroll_count.ts b/src/plugins/saved_objects_management/server/routes/scroll_count.ts index dfe361e7b9649..89a895adf6008 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_count.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_count.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter, SavedObjectsFindOptions } from 'src/core/server'; +import { chain } from 'lodash'; import { findAll } from '../lib'; export const registerScrollForCountRoute = (router: IRouter) => { @@ -30,18 +31,27 @@ export const registerScrollForCountRoute = (router: IRouter) => { }, }, router.handleLegacyErrors(async (context, req, res) => { - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; + const { typesToInclude, searchString, references } = req.body; + const includedHiddenTypes = chain(typesToInclude) + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); const findOptions: SavedObjectsFindOptions = { - type: req.body.typesToInclude, + type: typesToInclude, perPage: 1000, }; - if (req.body.searchString) { - findOptions.search = `${req.body.searchString}*`; + if (searchString) { + findOptions.search = `${searchString}*`; findOptions.searchFields = ['title']; } - if (req.body.references) { - findOptions.hasReference = req.body.references; + if (references) { + findOptions.hasReference = references; findOptions.hasReferenceOperator = 'OR'; } @@ -54,7 +64,7 @@ export const registerScrollForCountRoute = (router: IRouter) => { return accum; }, {} as Record); - for (const type of req.body.typesToInclude) { + for (const type of typesToInclude) { if (!counts[type]) { counts[type] = 0; } diff --git a/src/plugins/saved_objects_management/server/routes/scroll_export.ts b/src/plugins/saved_objects_management/server/routes/scroll_export.ts index 3417efa709e5f..8d11437af661b 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_export.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_export.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; +import { chain } from 'lodash'; import { findAll } from '../lib'; export const registerScrollForExportRoute = (router: IRouter) => { @@ -21,10 +22,20 @@ export const registerScrollForExportRoute = (router: IRouter) => { }, }, router.handleLegacyErrors(async (context, req, res) => { - const { client } = context.core.savedObjects; + const { typesToInclude } = req.body; + const { getClient, typeRegistry } = context.core.savedObjects; + const includedHiddenTypes = chain(typesToInclude) + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); + const objects = await findAll(client, { perPage: 1000, - type: req.body.typesToInclude, + type: typesToInclude, }); return res.ok({ diff --git a/test/functional/apps/dashboard/dashboard_listing.ts b/test/functional/apps/dashboard/dashboard_listing.ts index f89161ce8c499..86a3aac1f32c2 100644 --- a/test/functional/apps/dashboard/dashboard_listing.ts +++ b/test/functional/apps/dashboard/dashboard_listing.ts @@ -15,7 +15,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const listingTable = getService('listingTable'); - describe('dashboard listing page', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/86948 + describe.skip('dashboard listing page', function describeIndexTests() { const dashboardName = 'Dashboard Listing Test'; before(async function () { diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index bf0791d93fb2c..851d7ab7461ed 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { let originalPanelCount = 0; let unsavedPanelCount = 0; - describe('dashboard unsaved panels', () => { + // FLAKY: https://github.com/elastic/kibana/issues/91191 + describe.skip('dashboard unsaved panels', () => { before(async () => { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json new file mode 100644 index 0000000000000..6a272dc16e462 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json @@ -0,0 +1,29 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "test-hidden-importable-exportable:ff3733a0-9fty-11e7-ahb3-3dcb94193fab", + "source": { + "type": "test-hidden-importable-exportable", + "updated_at": "2021-02-11T18:51:23.794Z", + "test-hidden-importable-exportable": { + "title": "Hidden Saved object type that is importable/exportable." + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "test-hidden-non-importable-exportable:op3767a1-9rcg-53u7-jkb3-3dnb74193awc", + "source": { + "type": "test-hidden-non-importable-exportable", + "updated_at": "2021-02-11T18:51:23.794Z", + "test-hidden-non-importable-exportable": { + "title": "Hidden Saved object type that is not importable/exportable." + } + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json new file mode 100644 index 0000000000000..00d349a27795d --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json @@ -0,0 +1,513 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "test-export-transform": { + "properties": { + "title": { "type": "text" }, + "enabled": { "type": "boolean" } + } + }, + "test-export-add": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-add-dep": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform-error": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-invalid-transform": { + "properties": { + "title": { "type": "text" } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape", + "tree": "quadtree" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "test-hidden-non-importable-exportable": { + "properties": { + "title": { + "type": "text" + } + } + }, + "test-hidden-importable-exportable": { + "properties": { + "title": { + "type": "text" + } + } + } + } + } + } +} diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index 971bc2d48d22d..c28d351aa77fb 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -315,6 +315,18 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv }) ); } + + async getImportErrorsCount() { + log.debug(`Toggling overwriteAll`); + const errorCountNode = await testSubjects.find('importSavedObjectsErrorsCount'); + const errorCountText = await errorCountNode.getVisibleText(); + const match = errorCountText.match(/(\d)+/); + if (!match) { + throw Error(`unable to parse error count from text ${errorCountText}`); + } + + return +match[1]; + } } return new SavedObjectsPage(); diff --git a/test/plugin_functional/config.ts b/test/plugin_functional/config.ts index bd5ef814ae6c0..fc747fcd71f17 100644 --- a/test/plugin_functional/config.ts +++ b/test/plugin_functional/config.ts @@ -30,6 +30,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./test_suites/application_links'), require.resolve('./test_suites/data_plugin'), require.resolve('./test_suites/saved_objects_management'), + require.resolve('./test_suites/saved_objects_hidden_type'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json b/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json new file mode 100644 index 0000000000000..baef662c695d4 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "savedObjectsHiddenType", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["saved_objects_hidden_type"], + "server": true, + "ui": false +} diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/package.json b/test/plugin_functional/plugins/saved_objects_hidden_type/package.json new file mode 100644 index 0000000000000..af5212209d574 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/package.json @@ -0,0 +1,14 @@ +{ + "name": "saved_objects_hidden_type", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/saved_objects_hidden_type", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts b/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts new file mode 100644 index 0000000000000..2093b6e8449a4 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SavedObjectsHiddenTypePlugin } from './plugin'; + +export const plugin = () => new SavedObjectsHiddenTypePlugin(); diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts b/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts new file mode 100644 index 0000000000000..da2a0a2def1c2 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Plugin, CoreSetup } from 'kibana/server'; + +export class SavedObjectsHiddenTypePlugin implements Plugin { + public setup({ savedObjects }: CoreSetup, deps: {}) { + // example of a SO type that is hidden and importableAndExportable + savedObjects.registerType({ + name: 'test-hidden-importable-exportable', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + title: { type: 'text' }, + }, + }, + management: { + importableAndExportable: true, + }, + }); + + // example of a SO type that is hidden and not importableAndExportable + savedObjects.registerType({ + name: 'test-hidden-non-importable-exportable', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + title: { type: 'text' }, + }, + }, + management: { + importableAndExportable: false, + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json b/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json new file mode 100644 index 0000000000000..da457c9ba32fc --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts new file mode 100644 index 0000000000000..666afe1acedca --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('should return generic 404 when trying to delete a doc with importableAndExportable types', async () => + await supertest + .delete( + `/api/saved_objects/test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab` + ) + .set('kbn-xsrf', 'true') + .expect(404) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: + 'Saved object [test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab] not found', + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .delete( + `/api/saved_objects/test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc` + ) + .set('kbn-xsrf', 'true') + .expect(404) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: + 'Saved object [test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc] not found', + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts new file mode 100644 index 0000000000000..af25835db5a81 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +function ndjsonToObject(input: string): string[] { + return input.split('\n').map((str) => JSON.parse(str)); +} + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('export', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('exports objects with importableAndExportable types', async () => + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-hidden-importable-exportable'], + }) + .expect(200) + .then((resp) => { + const objects = ndjsonToObject(resp.text); + expect(objects).to.have.length(2); + expect(objects[0]).to.have.property('id', 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab'); + expect(objects[0]).to.have.property('type', 'test-hidden-importable-exportable'); + })); + + it('excludes objects with non importableAndExportable types', async () => + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-hidden-non-importable-exportable'], + }) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'Trying to export non-exportable type(s): test-hidden-non-importable-exportable', + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts new file mode 100644 index 0000000000000..723140f5c6bf5 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('returns empty response for importableAndExportable types', async () => + await supertest + .get('/api/saved_objects/_find?type=test-hidden-importable-exportable&fields=title') + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .get('/api/saved_objects/_find?type=test-hidden-non-importable-exportable&fields=title') + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts new file mode 100644 index 0000000000000..5de7d8375dd8c --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('import', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('imports objects with importableAndExportable type', async () => { + const fileBuffer = Buffer.from( + '{"id":"some-id-1","type":"test-hidden-importable-exportable","attributes":{"title":"my title"},"references":[]}', + 'utf8' + ); + await supertest + .post('/api/saved_objects/_import') + .set('kbn-xsrf', 'true') + .attach('file', fileBuffer, 'export.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 1, + success: true, + warnings: [], + successResults: [ + { + type: 'test-hidden-importable-exportable', + id: 'some-id-1', + meta: { + title: 'my title', + }, + }, + ], + }); + }); + }); + + it('does not import objects with non importableAndExportable type', async () => { + const fileBuffer = Buffer.from( + '{"id":"some-id-1","type":"test-hidden-non-importable-exportable","attributes":{"title":"my title"},"references":[]}', + 'utf8' + ); + await supertest + .post('/api/saved_objects/_import') + .set('kbn-xsrf', 'true') + .attach('file', fileBuffer, 'export.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 0, + success: false, + warnings: [], + errors: [ + { + id: 'some-id-1', + type: 'test-hidden-non-importable-exportable', + title: 'my title', + meta: { + title: 'my title', + }, + error: { + type: 'unsupported_type', + }, + }, + ], + }); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts new file mode 100644 index 0000000000000..00ba74a988cf4 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ loadTestFile }: PluginFunctionalProviderContext) { + describe('Saved objects with hidden type', function () { + loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./export')); + loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./interface/saved_objects_management')); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson new file mode 100644 index 0000000000000..a74585c07b868 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson @@ -0,0 +1 @@ +{"attributes": { "title": "Hidden Saved object type that is importable/exportable." }, "id":"ff3733a0-9fty-11e7-ahb3-3dcb94193fab", "references":[], "type":"test-hidden-importable-exportable", "version":1} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson new file mode 100644 index 0000000000000..25eea91b8bc43 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson @@ -0,0 +1 @@ +{"attributes": { "title": "Hidden Saved object type that is not importable/exportable." },"id":"op3767a1-9rcg-53u7-jkb3-3dnb74193awc","references":[],"type":"test-hidden-non-importable-exportable","version":1} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts new file mode 100644 index 0000000000000..dfd0b9dd07476 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'path'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../../services'; + +export default function ({ getPageObjects, getService }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); + const esArchiver = getService('esArchiver'); + const fixturePaths = { + hiddenImportable: path.join(__dirname, 'exports', '_import_hidden_importable.ndjson'), + hiddenNonImportable: path.join(__dirname, 'exports', '_import_hidden_non_importable.ndjson'), + }; + + describe('Saved objects management Interface', () => { + before(() => esArchiver.emptyKibanaIndex()); + beforeEach(async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + }); + describe('importable/exportable hidden type', () => { + it('imports objects successfully', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + }); + + it('shows test-hidden-importable-exportable in table', async () => { + await PageObjects.savedObjects.searchForObject('type:(test-hidden-importable-exportable)'); + const results = await PageObjects.savedObjects.getTableSummary(); + expect(results.length).to.be(1); + + const { title } = results[0]; + expect(title).to.be( + 'test-hidden-importable-exportable [id=ff3733a0-9fty-11e7-ahb3-3dcb94193fab]' + ); + }); + }); + + describe('non-importable/exportable hidden type', () => { + it('fails to import object', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenNonImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + + const errorsCount = await PageObjects.savedObjects.getImportErrorsCount(); + expect(errorsCount).to.be(1); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts new file mode 100644 index 0000000000000..dddee085ae22b --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('export', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('resolves objects with importableAndExportable types', async () => { + const fileBuffer = Buffer.from( + '{"id":"ff3733a0-9fty-11e7-ahb3-3dcb94193fab","type":"test-hidden-importable-exportable","attributes":{"title":"new title!"},"references":[]}', + 'utf8' + ); + + await supertest + .post('/api/saved_objects/_resolve_import_errors') + .set('kbn-xsrf', 'true') + .field( + 'retries', + JSON.stringify([ + { + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + type: 'test-hidden-importable-exportable', + overwrite: true, + }, + ]) + ) + .attach('file', fileBuffer, 'import.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 1, + success: true, + warnings: [], + successResults: [ + { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + meta: { + title: 'new title!', + }, + overwrite: true, + }, + ], + }); + }); + }); + + it('rejects objects with non importableAndExportable types', async () => { + const fileBuffer = Buffer.from( + '{"id":"op3767a1-9rcg-53u7-jkb3-3dnb74193awc","type":"test-hidden-non-importable-exportable","attributes":{"title":"new title!"},"references":[]}', + 'utf8' + ); + + await supertest + .post('/api/saved_objects/_resolve_import_errors') + .set('kbn-xsrf', 'true') + .field( + 'retries', + JSON.stringify([ + { + id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc', + type: 'test-hidden-non-importable-exportable', + overwrite: true, + }, + ]) + ) + .attach('file', fileBuffer, 'import.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 0, + success: false, + warnings: [], + errors: [ + { + id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc', + type: 'test-hidden-non-importable-exportable', + title: 'new title!', + meta: { + title: 'new title!', + }, + error: { + type: 'unsupported_type', + }, + overwrite: true, + }, + ], + }); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/find.ts b/test/plugin_functional/test_suites/saved_objects_management/find.ts new file mode 100644 index 0000000000000..5dce8f43339a1 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/find.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + it('returns saved objects with importableAndExportable types', async () => + await supertest + .get( + '/api/kibana/management/saved_objects/_find?type=test-hidden-importable-exportable&fields=title' + ) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + attributes: { + title: 'Hidden Saved object type that is importable/exportable.', + }, + references: [], + updated_at: '2021-02-11T18:51:23.794Z', + version: 'WzIsMl0=', + namespaces: ['default'], + score: 0, + meta: { + namespaceType: 'single', + }, + }, + ], + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .get( + '/api/kibana/management/saved_objects/_find?type=test-hidden-non-importable-exportable' + ) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/get.ts b/test/plugin_functional/test_suites/saved_objects_management/get.ts new file mode 100644 index 0000000000000..fa35983df8301 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/get.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + const hiddenTypeExportableImportable = + 'test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab'; + const hiddenTypeNonExportableImportable = + 'test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc'; + + it('should return 200 for hidden types that are importableAndExportable', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${hiddenTypeExportableImportable}`) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + const { body } = resp; + const { type, id, meta } = body; + expect(type).to.eql('test-hidden-importable-exportable'); + expect(id).to.eql('ff3733a0-9fty-11e7-ahb3-3dcb94193fab'); + expect(meta).to.not.equal(undefined); + })); + + it('should return 404 for hidden types that are not importableAndExportable', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${hiddenTypeNonExportableImportable}`) + .set('kbn-xsrf', 'true') + .expect(404)); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/index.ts b/test/plugin_functional/test_suites/saved_objects_management/index.ts index f6d383e60388d..9f2d28b582f78 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/index.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/index.ts @@ -10,6 +10,9 @@ import { PluginFunctionalProviderContext } from '../../services'; export default function ({ loadTestFile }: PluginFunctionalProviderContext) { describe('Saved Objects Management', function () { + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./scroll_count')); + loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./export_transform')); loadTestFile(require.resolve('./import_warnings')); }); diff --git a/test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts b/test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts new file mode 100644 index 0000000000000..f74cd5b938447 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const apiUrl = '/api/kibana/management/saved_objects/scroll/counts'; + + describe('scroll_count', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('only counts hidden types that are importableAndExportable', async () => { + const res = await supertest + .post(apiUrl) + .set('kbn-xsrf', 'true') + .send({ + typesToInclude: [ + 'test-hidden-non-importable-exportable', + 'test-hidden-importable-exportable', + ], + }) + .expect(200); + + expect(res.body).to.eql({ + 'test-hidden-importable-exportable': 1, + 'test-hidden-non-importable-exportable': 0, + }); + }); + }); + }); +} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index 9a8c2dffacaf7..3edf21eae7279 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -38,7 +38,7 @@ const Titles = euiStyled.div` const Label = euiStyled.div` margin-bottom: ${px(units.quarter)}; font-size: ${fontSizes.small}; - color: ${({ theme }) => theme.eui.euiColorMediumShade}; + color: ${({ theme }) => theme.eui.euiColorDarkShade}; `; const Message = euiStyled.div` diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts new file mode 100644 index 0000000000000..380cc0e354502 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SharedGlobalConfig, + ElasticsearchClient, + SavedObjectsErrorHelpers, + Logger, +} from '../../../../../src/core/server'; +import { BehaviorSubject } from 'rxjs'; +import { fetchProvider } from './fetch'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; + +describe('fetchProvider', () => { + let fetchFn: any; + let esClient: jest.Mocked; + let mockLogger: Logger; + + beforeEach(async () => { + const config$ = new BehaviorSubject({ + kibana: { + index: '123', + }, + } as any); + mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + } as any; + esClient = elasticsearchServiceMock.createElasticsearchClient(); + fetchFn = fetchProvider(config$, mockLogger); + }); + + test('returns when ES returns no results', async () => { + esClient.search.mockResolvedValue({ + statusCode: 200, + body: { + aggregations: { + persisted: { + buckets: [], + }, + }, + }, + } as any); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(0); + expect(collRes.persistedCount).toBe(0); + expect(collRes.totalCount).toBe(0); + expect(mockLogger.warn).not.toBeCalled(); + }); + + test('returns when ES throws an error', async () => { + esClient.search.mockRejectedValue( + SavedObjectsErrorHelpers.createTooManyRequestsError('a', 'b') + ); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(0); + expect(collRes.persistedCount).toBe(0); + expect(collRes.totalCount).toBe(0); + expect(mockLogger.warn).toBeCalledTimes(1); + }); + + test('returns when ES returns full buckets', async () => { + esClient.search.mockResolvedValue({ + statusCode: 200, + body: { + aggregations: { + persisted: { + buckets: [ + { + key_as_string: 'true', + doc_count: 10, + }, + { + key_as_string: 'false', + doc_count: 7, + }, + ], + }, + }, + }, + } as any); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(7); + expect(collRes.persistedCount).toBe(10); + expect(collRes.totalCount).toBe(17); + }); +}); diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts new file mode 100644 index 0000000000000..428de148fdd4f --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { SearchResponse } from 'elasticsearch'; +import { SharedGlobalConfig, Logger } from 'kibana/server'; +import { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; +import { SEARCH_SESSION_TYPE } from '../../common'; +import { ReportedUsage } from './register'; + +interface SessionPersistedTermsBucket { + key_as_string: 'false' | 'true'; + doc_count: number; +} + +export function fetchProvider(config$: Observable, logger: Logger) { + return async ({ esClient }: CollectorFetchContext): Promise => { + try { + const config = await config$.pipe(first()).toPromise(); + const { body: esResponse } = await esClient.search>({ + index: config.kibana.index, + body: { + size: 0, + aggs: { + persisted: { + terms: { + field: `${SEARCH_SESSION_TYPE}.persisted`, + }, + }, + }, + }, + }); + + const { buckets } = esResponse.aggregations.persisted; + if (!buckets.length) { + return { transientCount: 0, persistedCount: 0, totalCount: 0 }; + } + + const { transientCount = 0, persistedCount = 0 } = buckets.reduce( + (usage: Partial, bucket: SessionPersistedTermsBucket) => { + const key = bucket.key_as_string === 'false' ? 'transientCount' : 'persistedCount'; + return { ...usage, [key]: bucket.doc_count }; + }, + {} + ); + const totalCount = transientCount + persistedCount; + logger.debug(`fetchProvider | ${persistedCount} persisted | ${transientCount} transient`); + return { transientCount, persistedCount, totalCount }; + } catch (e) { + logger.warn(`fetchProvider | error | ${e.message}`); + return { transientCount: 0, persistedCount: 0, totalCount: 0 }; + } + }; +} diff --git a/x-pack/plugins/data_enhanced/server/collectors/index.ts b/x-pack/plugins/data_enhanced/server/collectors/index.ts new file mode 100644 index 0000000000000..4a82c76e96dee --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { registerUsageCollector } from './register'; diff --git a/x-pack/plugins/data_enhanced/server/collectors/register.ts b/x-pack/plugins/data_enhanced/server/collectors/register.ts new file mode 100644 index 0000000000000..fe96b7f7ced1b --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/register.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, Logger } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { fetchProvider } from './fetch'; + +export interface ReportedUsage { + transientCount: number; + persistedCount: number; + totalCount: number; +} + +export async function registerUsageCollector( + usageCollection: UsageCollectionSetup, + context: PluginInitializerContext, + logger: Logger +) { + try { + const collector = usageCollection.makeUsageCollector({ + type: 'search-session', + isReady: () => true, + fetch: fetchProvider(context.config.legacy.globalConfig$, logger), + schema: { + transientCount: { type: 'long' }, + persistedCount: { type: 'long' }, + totalCount: { type: 'long' }, + }, + }); + usageCollection.registerCollector(collector); + } catch (err) { + return; // kibana plugin is not enabled (test environment) + } +} diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index c3d342b8159e3..1037de4f79ea7 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -24,6 +24,7 @@ import { import { getUiSettings } from './ui_settings'; import type { DataEnhancedRequestHandlerContext } from './type'; import { ConfigSchema } from '../config'; +import { registerUsageCollector } from './collectors'; import { SecurityPluginSetup } from '../../security/server'; interface SetupDependencies { @@ -85,6 +86,10 @@ export class EnhancedDataServerPlugin this.sessionService.setup(core, { taskManager: deps.taskManager, }); + + if (deps.usageCollection) { + registerUsageCollector(deps.usageCollection, this.initializerContext, this.logger); + } } public start(core: CoreStart, { taskManager }: StartDependencies) { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts index 38cfad1a3b188..36f04be3b30b1 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts @@ -22,8 +22,8 @@ const PERCENT_SIGN_NAME = 'test%'; const PERCENT_SIGN_WITH_OTHER_CHARS_NAME = 'test%#'; const PERCENT_SIGN_25_SEQUENCE = 'test%25'; -const createPolicyTitle = 'Create Policy'; -const editPolicyTitle = 'Edit Policy'; +const createPolicyTitle = 'Create policy'; +const editPolicyTitle = 'Edit policy'; window.scrollTo = jest.fn(); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 7e1b7c5267a8b..83a13f0523a40 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -271,6 +271,9 @@ export const setup = async (arg?: { appServicesContext: Partial (): boolean => + exists(`${phase}-rolloverMinAgeInputIconTip`); + return { ...testBed, actions: { @@ -306,6 +309,7 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-warm'), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('warm'), ...createShrinkActions('warm'), ...createForceMergeActions('warm'), setReadonly: setReadonly('warm'), @@ -321,11 +325,13 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-cold'), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('cold'), ...createIndexPriorityActions('cold'), ...createSearchableSnapshotActions('cold'), }, delete: { ...createToggleDeletePhaseActions(), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('delete'), setMinAgeValue: setMinAgeValue('delete'), setMinAgeUnits: setMinAgeUnits('delete'), }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 6f325084938e8..f1a15d805faf8 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -769,6 +769,38 @@ describe('', () => { }); }); }); + describe('with rollover', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + isUsingDeprecatedDataRoleConfig: false, + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data: ['123'] }, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['abc'] }); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); + + await act(async () => { + testBed = await setup(); + }); + + const { component } = testBed; + component.update(); + }); + + test('shows rollover tip on minimum age', async () => { + const { actions } = testBed; + + await actions.warm.enable(true); + await actions.cold.enable(true); + await actions.delete.enablePhase(); + + expect(actions.warm.hasRolloverTipOnMinAge()).toBeTruthy(); + expect(actions.cold.hasRolloverTipOnMinAge()).toBeTruthy(); + expect(actions.delete.hasRolloverTipOnMinAge()).toBeTruthy(); + }); + }); + describe('without rollover', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); @@ -778,6 +810,7 @@ describe('', () => { nodesByRoles: { data: ['123'] }, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); await act(async () => { testBed = await setup({ @@ -799,6 +832,20 @@ describe('', () => { expect(actions.hot.searchableSnapshotsExists()).toBeFalsy(); expect(actions.cold.searchableSnapshotDisabledDueToRollover()).toBeTruthy(); }); + + test('hiding rollover tip on minimum age', async () => { + const { actions } = testBed; + await actions.hot.toggleDefaultRollover(false); + await actions.hot.toggleRollover(false); + + await actions.warm.enable(true); + await actions.cold.enable(true); + await actions.delete.enablePhase(); + + expect(actions.warm.hasRolloverTipOnMinAge()).toBeFalsy(); + expect(actions.cold.hasRolloverTipOnMinAge()).toBeFalsy(); + expect(actions.delete.hasRolloverTipOnMinAge()).toBeFalsy(); + }); }); describe('policy timeline', () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index dc4f1e31d3696..ccc553c58e899 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -13,4 +13,5 @@ export { FieldLoadingError } from './field_loading_error'; export { Timeline } from './timeline'; export { FormErrorsCallout } from './form_errors_callout'; export { PhaseFooter } from './phase_footer'; +export { InfinityIcon } from './infinity_icon'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index c77493476b929..6d4e2750bb2e8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -16,7 +16,6 @@ import { EuiCallOut, EuiTextColor, EuiSwitch, - EuiIconTip, EuiText, } from '@elastic/eui'; @@ -121,25 +120,12 @@ export const HotPhase: FunctionComponent = () => {
path="_meta.hot.customRollover.enabled"> {(field) => ( - <> - field.setValue(e.target.checked)} - data-test-subj="rolloverSwitch" - /> -   - - } - /> - + field.setValue(e.target.checked)} + data-test-subj="rolloverSwitch" + /> )} {isUsingRollover && ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx index 2f1a058f5a943..04b756dc23559 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -17,11 +17,12 @@ import { EuiFormRow, EuiSelect, EuiText, + EuiIconTip, } from '@elastic/eui'; import { getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; -import { UseField } from '../../../../form'; +import { UseField, useConfigurationIssues } from '../../../../form'; import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; @@ -62,6 +63,17 @@ const i18nTexts = { defaultMessage: 'nanoseconds', } ), + rolloverToolTipDescription: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minimumAge.rolloverToolTipDescription', + { + defaultMessage: + 'Data age is calculated from rollover. Rollover is configured in the hot phase.', + } + ), + minAgeUnitFieldSuffix: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minimumAge.minimumAgeFieldSuffixLabel', + { defaultMessage: 'old' } + ), }; interface Props { @@ -69,6 +81,7 @@ interface Props { } export const MinAgeField: FunctionComponent = ({ phase }): React.ReactElement => { + const { isUsingRollover } = useConfigurationIssues(); return ( {(field) => { @@ -110,6 +123,22 @@ export const MinAgeField: FunctionComponent = ({ phase }): React.ReactEle const { isInvalid: isUnitFieldInvalid } = getFieldValidityAndErrorMessage( unitField ); + const icon = ( + <> + {/* This element is rendered for testing purposes only */} +
+ + + ); + const selectAppendValue: Array< + string | React.ReactElement + > = isUsingRollover + ? [i18nTexts.minAgeUnitFieldSuffix, icon] + : [i18nTexts.minAgeUnitFieldSuffix]; return ( = ({ phase }): React.ReactEle unitField.setValue(e.target.value); }} isInvalid={isUnitFieldInvalid} - append={'old'} + append={selectAppendValue} data-test-subj={`${phase}-selectedMinimumAgeUnits`} aria-label={getUnitsAriaLabelForPhase(phase)} options={[ diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx index 8097ab51eb59e..c996c45171d2f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { FunctionComponent, memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiText, EuiIconTip } from '@elastic/eui'; @@ -18,7 +19,7 @@ import { AbsoluteTimings, } from '../../lib'; -import { InfinityIcon } from '../infinity_icon'; +import { InfinityIcon, LearnMoreLink } from '..'; import { TimelinePhaseText } from './components'; @@ -47,7 +48,7 @@ const SCORE_BUFFER_AMOUNT = 50; const i18nTexts = { title: i18n.translate('xpack.indexLifecycleMgmt.timeline.title', { - defaultMessage: 'Policy Summary', + defaultMessage: 'Policy summary', }), description: i18n.translate('xpack.indexLifecycleMgmt.timeline.description', { defaultMessage: 'This policy moves data through the following phases.', @@ -55,13 +56,6 @@ const i18nTexts = { hotPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.hotPhaseSectionTitle', { defaultMessage: 'Hot phase', }), - rolloverTooltip: i18n.translate( - 'xpack.indexLifecycleMgmt.timeline.hotPhaseRolloverToolTipContent', - { - defaultMessage: - 'How long it takes to reach the rollover criteria in the hot phase can vary. Data moves to the next phase when the time since rollover reaches the minimum age.', - } - ), warmPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle', { defaultMessage: 'Warm phase', }), @@ -143,6 +137,16 @@ export const Timeline: FunctionComponent = memo( {i18nTexts.description} +   + + } + /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 0c7b5565372a5..637fbd893aaa0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -142,10 +142,10 @@ export const EditPolicy: React.FunctionComponent = ({ history }) => {

{isNewPolicy ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create Policy', + defaultMessage: 'Create policy', }) : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit Policy {originalPolicyName}', + defaultMessage: 'Edit policy {originalPolicyName}', values: { originalPolicyName }, })}

diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx index c520243b5b24e..00c6b1f93ef88 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx @@ -7,97 +7,31 @@ import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; -import { encode } from 'rison-node'; -import { TimeRange } from '../../../../common/http_api/shared/time_range'; -import { useLinkProps, LinkDescriptor } from '../../../hooks/use_link_props'; +import React, { useCallback } from 'react'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { shouldHandleLinkEvent } from '../../../hooks/use_link_props'; export const AnalyzeInMlButton: React.FunctionComponent<{ - jobId: string; - partition?: string; - timeRange: TimeRange; -}> = ({ jobId, partition, timeRange }) => { - const linkProps = useLinkProps( - typeof partition === 'string' - ? getEntitySpecificSingleMetricViewerLink(jobId, timeRange, { - 'event.dataset': partition, - }) - : getOverallAnomalyExplorerLinkDescriptor(jobId, timeRange) - ); - const buttonLabel = ( - + href?: string; +}> = ({ href }) => { + const { + services: { application }, + } = useKibanaContextForPlugin(); + + const handleClick = useCallback( + (e) => { + if (!href || !shouldHandleLinkEvent(e)) return; + application.navigateToUrl(href); + }, + [href, application] ); - return typeof partition === 'string' ? ( - - {buttonLabel} - - ) : ( - - {buttonLabel} + + return ( + + ); }; - -export const getOverallAnomalyExplorerLinkDescriptor = ( - jobId: string, - timeRange: TimeRange -): LinkDescriptor => { - const { from, to } = convertTimeRangeToParams(timeRange); - - const _g = encode({ - ml: { - jobIds: [jobId], - }, - time: { - from, - to, - }, - }); - - return { - app: 'ml', - pathname: '/explorer', - search: { _g }, - }; -}; - -export const getEntitySpecificSingleMetricViewerLink = ( - jobId: string, - timeRange: TimeRange, - entities: Record -): LinkDescriptor => { - const { from, to } = convertTimeRangeToParams(timeRange); - - const _g = encode({ - ml: { - jobIds: [jobId], - }, - time: { - from, - to, - mode: 'absolute', - }, - }); - - const _a = encode({ - mlTimeSeriesExplorer: { - entities, - }, - }); - - return { - app: 'ml', - pathname: '/timeseriesexplorer', - search: { _g, _a }, - }; -}; - -const convertTimeRangeToParams = (timeRange: TimeRange): { from: string; to: string } => { - return { - from: new Date(timeRange.startTime).toISOString(), - to: new Date(timeRange.endTime).toISOString(), - }; -}; diff --git a/x-pack/plugins/infra/public/hooks/use_link_props.tsx b/x-pack/plugins/infra/public/hooks/use_link_props.tsx index 225ed5ae4a191..72a538cd56281 100644 --- a/x-pack/plugins/infra/public/hooks/use_link_props.tsx +++ b/x-pack/plugins/infra/public/hooks/use_link_props.tsx @@ -69,9 +69,10 @@ export const useLinkProps = ( const onClick = useMemo(() => { return (e: React.MouseEvent | React.MouseEvent) => { - if (e.defaultPrevented || isModifiedEvent(e)) { + if (!shouldHandleLinkEvent(e)) { return; } + e.preventDefault(); const navigate = () => { @@ -119,3 +120,7 @@ const validateParams = ({ app, pathname, hash, search }: LinkDescriptor) => { const isModifiedEvent = (event: any) => !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); + +export const shouldHandleLinkEvent = ( + e: React.MouseEvent | React.MouseEvent +) => !e.defaultPrevented && !isModifiedEvent(e); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx index ba3553611c0e6..15e27705395bb 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx @@ -6,12 +6,14 @@ */ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; -import React from 'react'; - +import React, { useCallback } from 'react'; +import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; import { TimeRange } from '../../../../../../common/time/time_range'; -import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results'; -import { useLinkProps } from '../../../../../hooks/use_link_props'; +import { useMlHref, ML_PAGES } from '../../../../../../../ml/public'; +import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; +import { shouldHandleLinkEvent } from '../../../../../hooks/use_link_props'; export const AnalyzeCategoryDatasetInMlAction: React.FunctionComponent<{ categorizationJobId: string; @@ -19,11 +21,32 @@ export const AnalyzeCategoryDatasetInMlAction: React.FunctionComponent<{ dataset: string; timeRange: TimeRange; }> = ({ categorizationJobId, categoryId, dataset, timeRange }) => { - const linkProps = useLinkProps( - getEntitySpecificSingleMetricViewerLink(categorizationJobId, timeRange, { - 'event.dataset': dataset, - mlcategory: `${categoryId}`, - }) + const { + services: { ml, http, application }, + } = useKibanaContextForPlugin(); + + const viewAnomalyInMachineLearningLink = useMlHref(ml, http.basePath.get(), { + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { + jobIds: [categorizationJobId], + timeRange: { + from: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + to: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + mode: 'absolute', + }, + entities: { + [partitionField]: dataset, + mlcategory: `${categoryId}`, + }, + }, + }); + + const handleClick = useCallback( + (e) => { + if (!viewAnomalyInMachineLearningLink || !shouldHandleLinkEvent(e)) return; + application.navigateToUrl(viewAnomalyInMachineLearningLink); + }, + [application, viewAnomalyInMachineLearningLink] ); return ( @@ -32,7 +55,8 @@ export const AnalyzeCategoryDatasetInMlAction: React.FunctionComponent<{ aria-label={analyseCategoryDatasetInMlButtonLabel} iconType="machineLearningApp" data-test-subj="analyzeCategoryDatasetInMlButton" - {...linkProps} + href={viewAnomalyInMachineLearningLink} + onClick={handleClick} /> ); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx index 1aa6aabf864cc..f5b94bce74e67 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -6,6 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiTitle } from '@elastic/eui'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -18,6 +19,8 @@ import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysi import { DatasetsSelector } from '../../../../../components/logging/log_analysis_results/datasets_selector'; import { TopCategoriesTable } from './top_categories_table'; import { SortOptions, ChangeSortOptions } from '../../use_log_entry_categories_results'; +import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; +import { useMlHref, ML_PAGES } from '../../../../../../../ml/public'; export const TopCategoriesSection: React.FunctionComponent<{ availableDatasets: string[]; @@ -48,6 +51,22 @@ export const TopCategoriesSection: React.FunctionComponent<{ sortOptions, changeSortOptions, }) => { + const { + services: { ml, http }, + } = useKibanaContextForPlugin(); + + const analyzeInMlLink = useMlHref(ml, http.basePath.get(), { + page: ML_PAGES.ANOMALY_EXPLORER, + pageState: { + jobIds: [jobId], + timeRange: { + from: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + to: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + mode: 'absolute', + }, + }, + }); + return ( <> @@ -66,7 +85,7 @@ export const TopCategoriesSection: React.FunctionComponent<{ />
- + diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index 1a794e6f78c39..4362f412d5a78 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -9,6 +9,8 @@ import React, { useMemo, useCallback, useState } from 'react'; import moment from 'moment'; import { encode } from 'rison-node'; import { i18n } from '@kbn/i18n'; +import { useMlHref, ML_PAGES } from '../../../../../../../ml/public'; +import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; import { @@ -25,10 +27,9 @@ import { LogColumnHeadersWrapper, LogColumnHeader, } from '../../../../../components/logging/log_text_stream/column_headers'; -import { useLinkProps } from '../../../../../hooks/use_link_props'; +import { useLinkProps, shouldHandleLinkEvent } from '../../../../../hooks/use_link_props'; import { TimeRange } from '../../../../../../common/time/time_range'; import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; -import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button'; import { LogEntryExample, isCategoryAnomaly } from '../../../../../../common/log_analysis'; import { LogColumnConfiguration, @@ -82,6 +83,9 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ timeRange, anomaly, }) => { + const { + services: { ml, http, application }, + } = useKibanaContextForPlugin(); const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const openMenu = useCallback(() => setIsMenuOpen(true), []); @@ -114,15 +118,32 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ }, }); - const viewAnomalyInMachineLearningLinkProps = useLinkProps( - getEntitySpecificSingleMetricViewerLink(anomaly.jobId, timeRange, { - [partitionField]: dataset, - ...(isCategoryAnomaly(anomaly) ? { mlcategory: anomaly.categoryId } : {}), - }) + const viewAnomalyInMachineLearningLink = useMlHref(ml, http.basePath.get(), { + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { + jobIds: [anomaly.jobId], + timeRange: { + from: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + to: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + mode: 'absolute', + }, + entities: { + [partitionField]: dataset, + ...(isCategoryAnomaly(anomaly) ? { mlcategory: anomaly.categoryId } : {}), + }, + }, + }); + + const handleMlLinkClick = useCallback( + (e) => { + if (!viewAnomalyInMachineLearningLink || !shouldHandleLinkEvent(e)) return; + application.navigateToUrl(viewAnomalyInMachineLearningLink); + }, + [viewAnomalyInMachineLearningLink, application] ); const menuItems = useMemo(() => { - if (!viewInStreamLinkProps.onClick || !viewAnomalyInMachineLearningLinkProps.onClick) { + if (!viewInStreamLinkProps.onClick || !viewAnomalyInMachineLearningLink) { return undefined; } @@ -140,11 +161,17 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ }, { label: VIEW_ANOMALY_IN_ML_LABEL, - onClick: viewAnomalyInMachineLearningLinkProps.onClick, - href: viewAnomalyInMachineLearningLinkProps.href, + onClick: handleMlLinkClick, + href: viewAnomalyInMachineLearningLink, }, ]; - }, [id, openLogEntryFlyout, viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); + }, [ + id, + openLogEntryFlyout, + viewInStreamLinkProps, + viewAnomalyInMachineLearningLink, + handleMlLinkClick, + ]); return ( { label: 'label', }); - const error = datatableVisualization.getErrorMessages( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, - frame - ); + const error = datatableVisualization.getErrorMessages({ + layerId: 'a', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }); expect(error).toBeUndefined(); }); @@ -478,10 +478,10 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, - frame - ); + const error = datatableVisualization.getErrorMessages({ + layerId: 'a', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }); expect(error).toBeUndefined(); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 9625a814c7958..47f8ce09aea68 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -276,7 +276,7 @@ export const datatableVisualization: Visualization }; }, - getErrorMessages(state, frame) { + getErrorMessages(state) { return undefined; }, diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index b3b695b22ad71..e5594bb0bb769 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -1,12 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DragDrop defined dropType is reflected in the className 1`] = ` - + +
`; exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = ` @@ -23,6 +27,7 @@ exports[`DragDrop items that has dropType=undefined get special styling when ano exports[`DragDrop renders if nothing is being dragged 1`] = `
+ ); @@ -96,7 +97,7 @@ describe('DragDrop', () => { jest.runAllTimers(); expect(dataTransfer.setData).toBeCalledWith('text', 'hello'); - expect(setDragging).toBeCalledWith(value); + expect(setDragging).toBeCalledWith({ ...value }); expect(setA11yMessage).toBeCalledWith('Lifted hello'); }); @@ -175,7 +176,7 @@ describe('DragDrop', () => { test('items that has dropType=undefined get special styling when another item is dragged', () => { const component = mount( - + @@ -198,7 +199,6 @@ describe('DragDrop', () => { const getAdditionalClassesOnEnter = jest.fn().mockReturnValue('additional'); const getAdditionalClassesOnDroppable = jest.fn().mockReturnValue('droppable'); const setA11yMessage = jest.fn(); - let activeDropTarget; const component = mount( { setDragging={() => { dragging = { id: '1', humanData: { label: 'label1' } }; }} - setActiveDropTarget={(val) => { - activeDropTarget = { activeDropTarget: val }; - }} - activeDropTarget={activeDropTarget} > { , style: {} } }, setActiveDropTarget, setA11yMessage, activeDropTarget: { @@ -376,21 +372,115 @@ describe('DragDrop', () => { .simulate('focus'); act(() => { keyboardHandler.simulate('keydown', { key: 'ArrowRight' }); - expect(setActiveDropTarget).toBeCalledWith({ - ...items[2].value, - onDrop, - dropType: items[2].dropType, - }); - keyboardHandler.simulate('keydown', { key: 'Enter' }); - expect(setA11yMessage).toBeCalledWith( - 'Selected label3 in group at position 1. Press space or enter to replace label3 with label1.' - ); - expect(setActiveDropTarget).toBeCalledWith(undefined); - expect(onDrop).toBeCalledWith( - { humanData: { label: 'label1', position: 1 }, id: '1' }, - 'move_compatible' - ); }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[2].value, + onDrop, + dropType: items[2].dropType, + }); + keyboardHandler.simulate('keydown', { key: 'Enter' }); + expect(setA11yMessage).toBeCalledWith( + 'Selected label3 in group at position 1. Press space or enter to replace label3 with label1.' + ); + expect(setActiveDropTarget).toBeCalledWith(undefined); + expect(onDrop).toBeCalledWith( + { humanData: { label: 'label1', position: 1 }, id: '1' }, + 'move_compatible' + ); + }); + + test('Keyboard navigation: dragstart sets dragging in the context and calls it with proper params', async () => { + const setDragging = jest.fn(); + + const setA11yMessage = jest.fn(); + const component = mount( + + + + + + ); + + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('focus'); + + keyboardHandler.simulate('keydown', { key: 'Enter' }); + jest.runAllTimers(); + + expect(setDragging).toBeCalledWith({ + ...value, + ghost: { + children: , + style: { + height: 0, + width: 0, + }, + }, + }); + expect(setA11yMessage).toBeCalledWith('Lifted hello'); + }); + + test('Keyboard navigation: ActiveDropTarget gets ghost image', () => { + const onDrop = jest.fn(); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'label1', position: 1 }, + }, + children: '1', + order: [2, 0, 0, 0], + }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + + value: { + id: '2', + + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropType: 'move_compatible' as DropType, + order: [2, 0, 1, 0], + }, + ]; + const component = mount( + Hello
, style: {} } }, + setActiveDropTarget, + setA11yMessage, + activeDropTarget: { + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + }, + }, + keyboardMode: true, + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + + expect(component.find(DragDrop).at(1).find('.lnsDragDrop_ghost').text()).toEqual('Hello'); }); describe('reordering', () => { @@ -427,7 +517,7 @@ describe('DragDrop', () => { const registerDropTarget = jest.fn(); const baseContext = { dragging, - setDragging: (val?: DragDropIdentifier) => { + setDragging: (val?: DraggingIdentifier) => { dragging = val; }, keyboardMode, @@ -479,7 +569,11 @@ describe('DragDrop', () => { test(`Reorderable group with lifted element renders properly`, () => { const setA11yMessage = jest.fn(); const setDragging = jest.fn(); - const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage }); + const component = mountComponent({ + dragging: { ...items[0] }, + setDragging, + setA11yMessage, + }); act(() => { component .find('[data-test-subj="lnsDragDrop"]') @@ -488,7 +582,7 @@ describe('DragDrop', () => { jest.runAllTimers(); }); - expect(setDragging).toBeCalledWith(items[0]); + expect(setDragging).toBeCalledWith({ ...items[0] }); expect(setA11yMessage).toBeCalledWith('Lifted label1'); expect( component @@ -498,7 +592,7 @@ describe('DragDrop', () => { }); test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => { - const component = mountComponent({ dragging: items[0] }); + const component = mountComponent({ dragging: { ...items[0] } }); act(() => { component @@ -545,7 +639,11 @@ describe('DragDrop', () => { const setA11yMessage = jest.fn(); const setDragging = jest.fn(); - const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage }); + const component = mountComponent({ + dragging: { ...items[0] }, + setDragging, + setA11yMessage, + }); component .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') @@ -558,14 +656,14 @@ describe('DragDrop', () => { ); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); - expect(onDrop).toBeCalledWith(items[0], 'reorder'); + expect(onDrop).toBeCalledWith({ ...items[0] }, 'reorder'); }); test(`Keyboard Navigation: User cannot move an element outside of the group`, () => { const setA11yMessage = jest.fn(); const setActiveDropTarget = jest.fn(); const component = mountComponent({ - dragging: items[0], + dragging: { ...items[0] }, keyboardMode: true, activeDropTarget: { activeDropTarget: undefined, @@ -594,7 +692,7 @@ describe('DragDrop', () => { }); test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { const component = mountComponent({ - dragging: items[0], + dragging: { ...items[0] }, activeDropTarget: { activeDropTarget: { ...items[2], dropType: 'reorder', onDrop }, dropTargetsByOrder: { @@ -621,7 +719,10 @@ describe('DragDrop', () => { test(`Keyboard Navigation: Doesn't call onDrop when movement is cancelled`, () => { const setA11yMessage = jest.fn(); const onDropHandler = jest.fn(); - const component = mountComponent({ dragging: items[0], setA11yMessage }, onDropHandler); + const component = mountComponent( + { dragging: { ...items[0] }, setA11yMessage }, + onDropHandler + ); const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'Escape' }); @@ -640,7 +741,7 @@ describe('DragDrop', () => { test(`Keyboard Navigation: Reordered elements get extra styles to show the reorder effect`, () => { const setA11yMessage = jest.fn(); const component = mountComponent({ - dragging: items[0], + dragging: { ...items[0] }, keyboardMode: true, activeDropTarget: { activeDropTarget: undefined, @@ -704,7 +805,7 @@ describe('DragDrop', () => { '2,0,1,1': { ...items[1], onDrop, dropType: 'reorder' }, }, }} - dragging={items[0]} + dragging={{ ...items[0] }} setActiveDropTarget={setActiveDropTarget} setA11yMessage={setA11yMessage} > diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 07c1368e53456..4b25064320327 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -177,6 +177,7 @@ export const DragDrop = (props: BaseProps) => { ); const dropProps = { ...props, + keyboardMode, setKeyboardMode, dragging, setDragging, @@ -219,7 +220,10 @@ const DragInner = memo(function DragInner({ ariaDescribedBy, setA11yMessage, }: DragInnerProps) { - const dragStart = (e?: DroppableEvent | React.KeyboardEvent) => { + const dragStart = ( + e: DroppableEvent | React.KeyboardEvent, + keyboardModeOn?: boolean + ) => { // Setting stopPropgagation causes Chrome failures, so // we are manually checking if we've already handled this // in a nested child, and doing nothing if so... @@ -237,9 +241,21 @@ const DragInner = memo(function DragInner({ // dragStart event, so we drop a setTimeout to avoid that. const currentTarget = e?.currentTarget; + setTimeout(() => { - setDragging(value); + setDragging({ + ...value, + ghost: keyboardModeOn + ? { + children, + style: { width: currentTarget.offsetWidth, height: currentTarget.offsetHeight }, + } + : undefined, + }); setA11yMessage(announce.lifted(value.humanData)); + if (keyboardModeOn) { + setKeyboardMode(true); + } if (onDragStart) { onDragStart(currentTarget); } @@ -284,8 +300,19 @@ const DragInner = memo(function DragInner({ : announce.noTarget() ); }; + const shouldShowGhostImageInstead = + isDragging && + dragType === 'move' && + keyboardMode && + activeDropTarget?.activeDropTarget && + activeDropTarget?.activeDropTarget.dropType !== 'reorder'; return ( -
+
, + style: {}, + }, }; const component = mountWithIntl( @@ -463,7 +467,7 @@ describe('LayerPanel', () => { }) ); - component.find('[data-test-subj="lnsGroup"] DragDrop').first().simulate('drop'); + component.find('[data-test-subj="lnsGroup"] DragDrop .lnsDragDrop').first().simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ @@ -497,6 +501,10 @@ describe('LayerPanel', () => { indexPatternId: 'a', id: '1', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -554,6 +562,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -571,7 +583,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the pre-populated dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop').at(0).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(0).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'b', @@ -582,7 +594,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the empty dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop').at(1).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(1).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', @@ -613,6 +625,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -659,6 +675,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index da1d7f6eacd02..108e4aa84418f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -1323,7 +1323,10 @@ describe('editor_frame', () => { getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (!dragging || dragging.id !== 'draggedField') { - setDragging({ id: 'draggedField', humanData: { label: 'draggedField' } }); + setDragging({ + id: 'draggedField', + humanData: { label: 'draggedField' }, + }); } }, }, @@ -1425,7 +1428,10 @@ describe('editor_frame', () => { getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (!dragging || dragging.id !== 'draggedField') { - setDragging({ id: 'draggedField', humanData: { label: '1' } }); + setDragging({ + id: 'draggedField', + humanData: { label: '1' }, + }); } }, }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 559e773dbc167..9c7ef19132c46 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -18,6 +18,9 @@ import { import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; +import { getActiveDatasourceIdFromDoc } from './state_management'; +import { ErrorMessage } from '../types'; +import { getMissingCurrentDatasource, getMissingVisualizationTypeError } from '../error_helper'; export async function initializeDatasources( datasourceMap: Record, @@ -72,7 +75,7 @@ export async function persistedStateToExpression( datasources: Record, visualizations: Record, doc: Document -): Promise { +): Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }> { const { state: { visualization: visualizationState, datasourceStates: persistedDatasourceStates }, visualizationType, @@ -80,7 +83,12 @@ export async function persistedStateToExpression( title, description, } = doc; - if (!visualizationType) return null; + if (!visualizationType) { + return { + ast: null, + errors: [{ shortMessage: '', longMessage: getMissingVisualizationTypeError() }], + }; + } const visualization = visualizations[visualizationType!]; const datasourceStates = await initializeDatasources( datasources, @@ -97,15 +105,33 @@ export async function persistedStateToExpression( const datasourceLayers = createDatasourceLayers(datasources, datasourceStates); - return buildExpression({ - title, - description, + const datasourceId = getActiveDatasourceIdFromDoc(doc); + if (datasourceId == null) { + return { + ast: null, + errors: [{ shortMessage: '', longMessage: getMissingCurrentDatasource() }], + }; + } + const validationResult = validateDatasourceAndVisualization( + datasources[datasourceId], + datasourceStates[datasourceId].state, visualization, visualizationState, - datasourceMap: datasources, - datasourceStates, - datasourceLayers, - }); + { datasourceLayers } + ); + + return { + ast: buildExpression({ + title, + description, + visualization, + visualizationState, + datasourceMap: datasources, + datasourceStates, + datasourceLayers, + }), + errors: validationResult, + }; } export const validateDatasourceAndVisualization = ( @@ -113,13 +139,8 @@ export const validateDatasourceAndVisualization = ( currentDatasourceState: unknown | null, currentVisualization: Visualization | null, currentVisualizationState: unknown | undefined, - frameAPI: FramePublicAPI -): - | Array<{ - shortMessage: string; - longMessage: string; - }> - | undefined => { + frameAPI: Pick +): ErrorMessage[] | undefined => { const layersGroups = currentVisualizationState ? currentVisualization ?.getLayerIds(currentVisualizationState) @@ -141,7 +162,7 @@ export const validateDatasourceAndVisualization = ( : undefined; const visualizationValidationErrors = currentVisualizationState - ? currentVisualization?.getErrorMessages(currentVisualizationState, frameAPI) + ? currentVisualization?.getErrorMessages(currentVisualizationState) : undefined; if (datasourceValidationErrors?.length || visualizationValidationErrors?.length) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 2c4cecd356ced..83d2100a832cf 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -10,16 +10,7 @@ import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n/react'; import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiText, - EuiTextColor, - EuiButtonEmpty, - EuiLink, - EuiTitle, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiButtonEmpty, EuiLink } from '@elastic/eui'; import { CoreStart, CoreSetup } from 'kibana/public'; import { DataPublicPluginStart, @@ -155,10 +146,7 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ datasourceLayers: framePublicAPI.datasourceLayers, }); } catch (e) { - const buildMessages = activeVisualization?.getErrorMessages( - visualizationState, - framePublicAPI - ); + const buildMessages = activeVisualization?.getErrorMessages(visualizationState); const defaultMessage = { shortMessage: i18n.translate('xpack.lens.editorFrame.buildExpressionError', { defaultMessage: 'An unexpected error occurred while preparing the chart', @@ -423,16 +411,6 @@ export const InnerVisualizationWrapper = ({ - - - - - - - {localState.configurationValidationError[0].longMessage} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index ae9294c474b42..0ace88b3d3ab7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -2,7 +2,6 @@ .lnsWorkspacePanelWrapper { @include euiScrollBar; - overflow: hidden; // Override panel size padding padding: 0 !important; // sass-lint:disable-line no-important margin-bottom: $euiSize; @@ -10,6 +9,7 @@ flex-direction: column; position: relative; // For positioning the dnd overlay min-height: $euiSizeXXL * 10; + overflow: visible; .lnsWorkspacePanelWrapper__pageContentBody { @include euiScrollBar; @@ -17,7 +17,6 @@ display: flex; align-items: stretch; justify-content: stretch; - overflow: auto; > * { flex: 1 1 100%; @@ -34,6 +33,8 @@ // Color the whole panel instead background-color: transparent !important; // sass-lint:disable-line no-important border: none !important; // sass-lint:disable-line no-important + width: 100%; + height: 100%; } .lnsExpressionRenderer { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index d2085a4cc8a8b..227c8b4741501 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -116,11 +116,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { @@ -140,6 +143,36 @@ describe('embeddable', () => { | expression`); }); + it('should not render the visualization if any error arises', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: [{ shortMessage: '', longMessage: 'my validation error' }], + }), + }, + {} as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({} as LensEmbeddableInput); + embeddable.render(mountpoint); + + expect(expressionRenderer).toHaveBeenCalledTimes(0); + }); + it('should initialize output with deduped list of index patterns', async () => { attributeService = attributeServiceMockFromSavedVis({ ...savedVis, @@ -162,11 +195,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, {} as LensEmbeddableInput @@ -194,11 +230,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -232,11 +271,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -265,11 +307,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -312,11 +357,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -359,11 +407,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -405,11 +456,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -440,11 +494,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -475,11 +532,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -510,11 +570,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123', timeRange, query, filters } as LensEmbeddableInput diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index dc5f9b366e6b5..ef265881f6eb3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -51,6 +51,7 @@ import { IndexPatternsContract } from '../../../../../../src/plugins/data/public import { getEditPath, DOC_TYPE } from '../../../common'; import { IBasePath } from '../../../../../../src/core/public'; import { LensAttributeService } from '../../lens_attribute_service'; +import type { ErrorMessage } from '../types'; export type LensSavedObjectAttributes = Omit; @@ -77,7 +78,9 @@ export interface LensEmbeddableOutput extends EmbeddableOutput { export interface LensEmbeddableDeps { attributeService: LensAttributeService; - documentToExpression: (doc: Document) => Promise; + documentToExpression: ( + doc: Document + ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; editable: boolean; indexPatternService: IndexPatternsContract; expressionRenderer: ReactExpressionRendererType; @@ -99,6 +102,7 @@ export class Embeddable private subscription: Subscription; private isInitialized = false; private activeData: Partial | undefined; + private errors: ErrorMessage[] | undefined; private externalSearchContext: { timeRange?: TimeRange; @@ -225,8 +229,9 @@ export class Embeddable type: this.type, savedObjectId: (input as LensByReferenceInput)?.savedObjectId, }; - const expression = await this.deps.documentToExpression(this.savedVis); - this.expression = expression ? toExpression(expression) : null; + const { ast, errors } = await this.deps.documentToExpression(this.savedVis); + this.errors = errors; + this.expression = ast ? toExpression(ast) : null; await this.initializeOutput(); this.isInitialized = true; } @@ -279,6 +284,7 @@ export class Embeddable Promise; + documentToExpression: ( + doc: Document + ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; } export class EmbeddableFactory implements EmbeddableFactoryDefinition { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 8873388633552..a559e6a02419d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon, EuiEmptyPrompt } from '@elastic/eui'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -18,10 +18,12 @@ import { ExecutionContextSearch } from 'src/plugins/data/public'; import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions'; import classNames from 'classnames'; import { getOriginalRequestErrorMessage } from '../error_helper'; +import { ErrorMessage } from '../types'; export interface ExpressionWrapperProps { ExpressionRenderer: ReactExpressionRendererType; expression: string | null; + errors: ErrorMessage[] | undefined; variables?: Record; searchContext: ExecutionContextSearch; searchSessionId?: string; @@ -37,6 +39,46 @@ export interface ExpressionWrapperProps { className?: string; } +interface VisualizationErrorProps { + errors: ExpressionWrapperProps['errors']; +} + +export function VisualizationErrorPanel({ errors }: VisualizationErrorProps) { + return ( +
+ + {errors ? ( + <> +

{errors[0].longMessage}

+ {errors.length > 1 ? ( +

+ +

+ ) : null} + + ) : ( +

+ +

+ )} + + } + /> +
+ ); +} + export function ExpressionWrapper({ ExpressionRenderer: ExpressionRendererComponent, expression, @@ -50,23 +92,12 @@ export function ExpressionWrapper({ hasCompatibleActions, style, className, + errors, }: ExpressionWrapperProps) { return ( - {expression === null || expression === '' ? ( - - - - - - - - - - + {errors || expression === null || expression === '' ? ( + ) : (
{ setDimension: jest.fn(), removeDimension: jest.fn(), - getErrorMessages: jest.fn((_state, _frame) => undefined), + getErrorMessages: jest.fn((_state) => undefined), }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 9e54a4d630dc2..8769aceca3bfd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -72,7 +72,7 @@ export class EditorFrameService { * This is an asynchronous process and should only be triggered once for a saved object. * @param doc parsed Lens saved object */ - private async documentToExpression(doc: Document) { + private documentToExpression = async (doc: Document) => { const [resolvedDatasources, resolvedVisualizations] = await Promise.all([ collectAsyncDefinitions(this.datasources), collectAsyncDefinitions(this.visualizations), @@ -81,7 +81,7 @@ export class EditorFrameService { const { persistedStateToExpression } = await import('../async_services'); return await persistedStateToExpression(resolvedDatasources, resolvedVisualizations, doc); - } + }; public setup( core: CoreSetup, @@ -98,7 +98,7 @@ export class EditorFrameService { coreHttp: coreStart.http, timefilter: deps.data.query.timefilter.timefilter, expressionRenderer: deps.expressions.ReactExpressionRenderer, - documentToExpression: this.documentToExpression.bind(this), + documentToExpression: this.documentToExpression, indexPatternService: deps.data.indexPatterns, uiActions: deps.uiActions, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/types.ts b/x-pack/plugins/lens/public/editor_frame_service/types.ts index dc5a4aa0e234b..6043e96343899 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/types.ts @@ -8,3 +8,8 @@ import { Datatable } from 'src/plugins/expressions'; export type TableInspectorAdapter = Record; + +export interface ErrorMessage { + shortMessage: string; + longMessage: string; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 6dffeb351d260..8f5da64fcc9a8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -59,6 +59,9 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri const [inputValue, setInputValue] = useState(value); const unflushedChanges = useRef(false); + // Save the initial value + const initialValue = useRef(value); + const onChangeDebounced = useMemo(() => { const callback = _.debounce((val: string) => { onChange(val); @@ -79,7 +82,7 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri const handleInputChange = (e: React.ChangeEvent) => { const val = String(e.target.value); setInputValue(val); - onChangeDebounced(val); + onChangeDebounced(val || initialValue.current); }; return ( @@ -96,6 +99,7 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri data-test-subj="indexPattern-label-edit" value={inputValue} onChange={handleInputChange} + placeholder={initialValue.current} /> ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index b374be98748f0..1f0381d92ce64 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternDimensionEditorProps } from './dimension_panel'; import { onDrop, getDropTypes } from './droppable'; @@ -187,7 +186,11 @@ describe('IndexPatternDimensionEditorPanel', () => { groupId, dragDropContext: { ...dragDropContext, - dragging: { name: 'bar', id: 'bar', humanData: { label: 'Label' } }, + dragging: { + name: 'bar', + id: 'bar', + humanData: { label: 'Label' }, + }, }, }) ).toBe(undefined); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss index 8a6e10c8be6e4..19f5b91975202 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss @@ -29,6 +29,13 @@ } } +.kbnFieldButton.lnsDragDrop_ghost { + .lnsFieldItem__infoIcon { + visibility: hidden; + opacity: 0; + } +} + .kbnFieldButton__name { transition: background-color $euiAnimSpeedFast ease-in-out; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 7fdc58b74e509..bc361973bb62c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -192,22 +192,21 @@ const MovingAveragePopup = () => {

@@ -227,7 +226,7 @@ const MovingAveragePopup = () => {

    @@ -240,7 +239,7 @@ const MovingAveragePopup = () => {

    diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts index c0c3030cb598a..59dbf74c11480 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts @@ -36,7 +36,7 @@ export function checkForDateHistogram(layer: IndexPatternLayer, name: string) { return [ i18n.translate('xpack.lens.indexPattern.calculations.dateHistogramErrorMessage', { defaultMessage: - '{name} requires a date histogram to work. Choose a different function or add a date histogram.', + '{name} requires a date histogram to work. Add a date histogram or select a different function.', values: { name, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index fa3a390fb199d..4d4556a0ac4ad 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -369,16 +369,14 @@ const AutoDateHistogramPopover = ({ data }: { data: DataPublicPluginStart }) => >

    {i18n.translate('xpack.lens.indexPattern.dateHistogram.autoBasicExplanation', { - defaultMessage: 'The auto date histogram splits a date field into buckets by interval.', + defaultMessage: 'The auto date histogram splits a data field into buckets by interval.', })}

    {UI_SETTINGS.HISTOGRAM_MAX_BARS}, targetBarSetting: {UI_SETTINGS.HISTOGRAM_BAR_TARGET}, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx index f58e2d788b9c8..269c59822fefc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx @@ -55,10 +55,8 @@ const GranularityHelpPopover = () => {

    {UI_SETTINGS.HISTOGRAM_MAX_BARS}, }} @@ -68,7 +66,7 @@ const GranularityHelpPopover = () => {

    {i18n.translate('xpack.lens.indexPattern.ranges.granularityPopoverAdvancedExplanation', { defaultMessage: - 'Intervals are incremented by 10, 5 or 2: for example an interval can be 100 or 0.2 .', + 'Intervals are incremented by 10, 5 or 2. For example, an interval can be 100 or 0.2 .', })}

    diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 517cb941f2f67..3b0cb67cbce41 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -16,6 +16,7 @@ import { EuiPopover, EuiButtonEmpty, EuiText, + EuiIconTip, } from '@elastic/eui'; import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public'; @@ -316,9 +317,25 @@ export const termsOperation: OperationDefinition )} + {i18n.translate('xpack.lens.indexPattern.terms.orderBy', { + defaultMessage: 'Rank by', + })}{' '} + + + } display="columnCompressed" fullWidth > @@ -338,14 +355,30 @@ export const termsOperation: OperationDefinition + {i18n.translate('xpack.lens.indexPattern.terms.orderDirection', { + defaultMessage: 'Rank direction', + })}{' '} + + + } display="columnCompressed" fullWidth > @@ -378,7 +411,7 @@ export const termsOperation: OperationDefinition diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts index 84abc38bf4106..66e524435ebc8 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts @@ -197,23 +197,7 @@ describe('metric_visualization', () => { describe('#getErrorMessages', () => { it('returns undefined if no error is raised', () => { - const datasource: DatasourcePublicAPI = { - ...createMockDatasource('l1').publicAPIMock, - getOperationForColumnId(_: string) { - return { - id: 'a', - dataType: 'number', - isBucketed: false, - label: 'shazm', - }; - }, - }; - const frame = { - ...mockFrame(), - datasourceLayers: { l1: datasource }, - }; - - const error = metricVisualization.getErrorMessages(exampleState(), frame); + const error = metricVisualization.getErrorMessages(exampleState()); expect(error).not.toBeDefined(); }); diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index b86ba71083440..91516b7b7319b 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -117,7 +117,7 @@ export const metricVisualization: Visualization = { return { ...prevState, accessor: undefined }; }, - getErrorMessages(state, frame) { + getErrorMessages(state) { // Is it possible to break it? return undefined; }, diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 5ec97e90e57d9..e3bd54032a93c 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -17,6 +17,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { Position } from '@elastic/charts'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PieVisualizationState, SharedPieLayerState } from './types'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; @@ -250,11 +251,15 @@ const DecimalPlaceSlider = ({ ); }; -export function DimensionEditor(props: VisualizationDimensionEditorProps) { +export function DimensionEditor( + props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } +) { return ( <> { props.setState({ ...props.state, palette: newPalette }); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts index 52fd4daac63c5..0cdeaa8c043d8 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts @@ -7,8 +7,6 @@ import { getPieVisualization } from './visualization'; import { PieVisualizationState } from './types'; -import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; -import { DatasourcePublicAPI, FramePublicAPI } from '../types'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; jest.mock('../id_generator'); @@ -36,37 +34,11 @@ function exampleState(): PieVisualizationState { }; } -function mockFrame(): FramePublicAPI { - return { - ...createMockFramePublicAPI(), - addNewLayer: () => LAYER_ID, - datasourceLayers: { - [LAYER_ID]: createMockDatasource(LAYER_ID).publicAPIMock, - }, - }; -} - // Just a basic bootstrap here to kickstart the tests describe('pie_visualization', () => { describe('#getErrorMessages', () => { it('returns undefined if no error is raised', () => { - const datasource: DatasourcePublicAPI = { - ...createMockDatasource('l1').publicAPIMock, - getOperationForColumnId(_: string) { - return { - id: 'a', - dataType: 'number', - isBucketed: false, - label: 'shazm', - }; - }, - }; - const frame = { - ...mockFrame(), - datasourceLayers: { l1: datasource }, - }; - - const error = pieVisualization.getErrorMessages(exampleState(), frame); + const error = pieVisualization.getErrorMessages(exampleState()); expect(error).not.toBeDefined(); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 6408d7496d332..683acc49859b6 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -227,7 +227,7 @@ export const getPieVisualization = ({ renderDimensionEditor(domElement, props) { render( - + , domElement ); @@ -274,7 +274,7 @@ export const getPieVisualization = ({ )); }, - getErrorMessages(state, frame) { + getErrorMessages(state) { // not possible to break it? return undefined; }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index cccc35acb3fca..ba02a3376bae7 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -358,7 +358,7 @@ export interface LensMultiTable { export interface VisualizationConfigProps { layerId: string; - frame: FramePublicAPI; + frame: Pick; state: T; } @@ -631,10 +631,7 @@ export interface Visualization { * The frame will call this function on all visualizations at few stages (pre-build/build error) in order * to provide more context to the error and show it to the user */ - getErrorMessages: ( - state: T, - frame: FramePublicAPI - ) => Array<{ shortMessage: string; longMessage: string }> | undefined; + getErrorMessages: (state: T) => Array<{ shortMessage: string; longMessage: string }> | undefined; /** * The frame calls this function to display warnings about visualization diff --git a/x-pack/plugins/lens/public/visualization_container.scss b/x-pack/plugins/lens/public/visualization_container.scss index a67aa50127c81..d40a0b48ab40e 100644 --- a/x-pack/plugins/lens/public/visualization_container.scss +++ b/x-pack/plugins/lens/public/visualization_container.scss @@ -15,3 +15,11 @@ position: static; // Let the progress indicator position itself against the outer parent } } + +.lnsEmbeddedError { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 27cc16ebf862b..d2e87ece5b5ec 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -95,7 +95,7 @@ export function getColorAssignments( export function getAccessorColorConfig( colorAssignments: ColorAssignments, - frame: FramePublicAPI, + frame: Pick, layer: XYLayerConfig, paletteService: PaletteRegistry ): AccessorConfig[] { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index cdb7f452cf7cf..c244fa7fdfc89 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -589,137 +589,119 @@ describe('xy_visualization', () => { describe('#getErrorMessages', () => { it("should not return an error when there's only one dimension (X or Y)", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + ], + }) ).not.toBeDefined(); }); it("should not return an error when there's only one dimension on multiple layers (same axis everywhere)", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + ], + }) ).not.toBeDefined(); }); it('should not return an error when mixing different valid configurations in multiple layers', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: ['a'], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: ['a'], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); }); it("should not return an error when there's only one splitAccessor dimension configured", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); }); it('should return an error when there are multiple layers, one axis configured for each layer (but different axis from each other)', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: ['a'], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: ['a'], + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', @@ -729,34 +711,31 @@ describe('xy_visualization', () => { }); it('should return an error with batched messages for the same error with multiple layers', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - { - layerId: 'third', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + { + layerId: 'third', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', @@ -766,32 +745,29 @@ describe('xy_visualization', () => { }); it("should return an error when some layers are complete but other layers aren't", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'third', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'third', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index a4dc7a91822bd..1ee4b2e050f3e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -340,7 +340,7 @@ export const getXyVisualization = ({ toExpression(state, layers, paletteService, attributes), toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), - getErrorMessages(state, frame) { + getErrorMessages(state) { // Data error handling below here const hasNoAccessors = ({ accessors }: XYLayerConfig) => accessors == null || accessors.length === 0; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index dd5fff3c49f4f..ac08c55eeadbf 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -336,7 +336,7 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp { setState(updateLayer(state, { ...layer, palette: newPalette }, index)); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts index c72b0eb5fd66e..216b0d8d5e992 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts @@ -6,6 +6,4 @@ */ export { useScatterplotFieldOptions } from './use_scatterplot_field_options'; -export { LEGEND_TYPES } from './scatterplot_matrix_vega_lite_spec'; -export { ScatterplotMatrix } from './scatterplot_matrix'; -export type { ScatterplotMatrixViewProps as ScatterplotMatrixProps } from './scatterplot_matrix_view'; +export { ScatterplotMatrix, ScatterplotMatrixProps } from './scatterplot_matrix'; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss similarity index 100% rename from x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss rename to x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx index 8a10fd5574ba5..a4f68c84ba81f 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx @@ -5,15 +5,305 @@ * 2.0. */ -import React, { FC, Suspense } from 'react'; +import React, { useMemo, useEffect, useState, FC } from 'react'; -import type { ScatterplotMatrixViewProps } from './scatterplot_matrix_view'; -import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSwitch, +} from '@elastic/eui'; -const ScatterplotMatrixLazy = React.lazy(() => import('./scatterplot_matrix_view')); +import { i18n } from '@kbn/i18n'; -export const ScatterplotMatrix: FC = (props) => ( - }> - - -); +import type { SearchResponse7 } from '../../../../common/types/es_client'; +import type { ResultsSearchQuery } from '../../data_frame_analytics/common/analytics'; + +import { useMlApiContext } from '../../contexts/kibana'; + +import { getProcessedFields } from '../data_grid'; +import { useCurrentEuiTheme } from '../color_range_legend'; + +// Separate imports for lazy loadable VegaChart and related code +import { VegaChart } from '../vega_chart'; +import type { LegendType } from '../vega_chart/common'; +import { VegaChartLoading } from '../vega_chart/vega_chart_loading'; + +import { + getScatterplotMatrixVegaLiteSpec, + OUTLIER_SCORE_FIELD, +} from './scatterplot_matrix_vega_lite_spec'; + +import './scatterplot_matrix.scss'; + +const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; + +const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { + defaultMessage: 'On', +}); +const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { + defaultMessage: 'Off', +}); + +const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); + +export interface ScatterplotMatrixProps { + fields: string[]; + index: string; + resultsField?: string; + color?: string; + legendType?: LegendType; + searchQuery?: ResultsSearchQuery; +} + +export const ScatterplotMatrix: FC = ({ + fields: allFields, + index, + resultsField, + color, + legendType, + searchQuery, +}) => { + const { esSearch } = useMlApiContext(); + + // dynamicSize is optionally used for outlier charts where the scatterplot marks + // are sized according to outlier_score + const [dynamicSize, setDynamicSize] = useState(false); + + // used to give the use the option to customize the fields used for the matrix axes + const [fields, setFields] = useState([]); + + useEffect(() => { + const defaultFields = + allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS + ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) + : allFields; + setFields(defaultFields); + }, [allFields]); + + // the amount of documents to be fetched + const [fetchSize, setFetchSize] = useState(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); + // flag to add a random score to the ES query to fetch documents + const [randomizeQuery, setRandomizeQuery] = useState(false); + + const [isLoading, setIsLoading] = useState(false); + + // contains the fetched documents and columns to be passed on to the Vega spec. + const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); + + // formats the array of field names for EuiComboBox + const fieldOptions = useMemo( + () => + allFields.map((d) => ({ + label: d, + })), + [allFields] + ); + + const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { + setFields(newFields.map((d) => d.label)); + }; + + const fetchSizeOnChange = (e: React.ChangeEvent) => { + setFetchSize( + Math.min( + Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), + SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE + ) + ); + }; + + const randomizeQueryOnChange = () => { + setRandomizeQuery(!randomizeQuery); + }; + + const dynamicSizeOnChange = () => { + setDynamicSize(!dynamicSize); + }; + + const { euiTheme } = useCurrentEuiTheme(); + + useEffect(() => { + if (fields.length === 0) { + setSplom(undefined); + setIsLoading(false); + return; + } + + async function fetchSplom(options: { didCancel: boolean }) { + setIsLoading(true); + try { + const queryFields = [ + ...fields, + ...(color !== undefined ? [color] : []), + ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), + ]; + + const queryFallback = searchQuery !== undefined ? searchQuery : { match_all: {} }; + const query = randomizeQuery + ? { + function_score: { + query: queryFallback, + random_score: { seed: 10, field: '_seq_no' }, + }, + } + : queryFallback; + + const resp: SearchResponse7 = await esSearch({ + index, + body: { + fields: queryFields, + _source: false, + query, + from: 0, + size: fetchSize, + }, + }); + + if (!options.didCancel) { + const items = resp.hits.hits.map((d) => + getProcessedFields(d.fields, (key: string) => + key.startsWith(`${resultsField}.feature_importance`) + ) + ); + + setSplom({ columns: fields, items }); + setIsLoading(false); + } + } catch (e) { + // TODO error handling + setIsLoading(false); + } + } + + const options = { didCancel: false }; + fetchSplom(options); + return () => { + options.didCancel = true; + }; + // stringify the fields array and search, otherwise the comparator will trigger on new but identical instances. + }, [fetchSize, JSON.stringify({ fields, searchQuery }), index, randomizeQuery, resultsField]); + + const vegaSpec = useMemo(() => { + if (splom === undefined) { + return; + } + + const { items, columns } = splom; + + const values = + resultsField !== undefined + ? items + : items.map((d) => { + d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; + return d; + }); + + return getScatterplotMatrixVegaLiteSpec( + values, + columns, + euiTheme, + resultsField, + color, + legendType, + dynamicSize + ); + }, [resultsField, splom, color, legendType, dynamicSize]); + + return ( + <> + {splom === undefined || vegaSpec === undefined ? ( + + ) : ( +
    + + + + ({ + label: d, + }))} + onChange={fieldsOnChange} + isClearable={true} + data-test-subj="mlScatterplotMatrixFieldsComboBox" + /> + + + + + + + + + + + + + {resultsField !== undefined && legendType === undefined && ( + + + + + + )} + + + +
    + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts index 44fba189e856c..c963b7509139b 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts @@ -10,13 +10,14 @@ import { compile } from 'vega-lite/build-es5/vega-lite'; import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json'; +import { LEGEND_TYPES } from '../vega_chart/common'; + import { getColorSpec, getScatterplotMatrixVegaLiteSpec, COLOR_OUTLIER, COLOR_RANGE_NOMINAL, DEFAULT_COLOR, - LEGEND_TYPES, } from './scatterplot_matrix_vega_lite_spec'; describe('getColorSpec()', () => { diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts index e476123ad0f2a..f99aa7c5c3de8 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts @@ -15,11 +15,7 @@ import { euiPaletteColorBlind, euiPaletteNegative, euiPalettePositive } from '@e import { i18n } from '@kbn/i18n'; -export const LEGEND_TYPES = { - NOMINAL: 'nominal', - QUANTITATIVE: 'quantitative', -} as const; -export type LegendType = typeof LEGEND_TYPES[keyof typeof LEGEND_TYPES]; +import { LegendType, LEGEND_TYPES } from '../vega_chart/common'; export const OUTLIER_SCORE_FIELD = 'outlier_score'; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx deleted file mode 100644 index 7d32992ace84d..0000000000000 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx +++ /dev/null @@ -1,324 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useEffect, useState, FC } from 'react'; - -// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. -// @ts-ignore -import { compile } from 'vega-lite/build-es5/vega-lite'; -import { parse, View, Warn } from 'vega'; -import { Handler } from 'vega-tooltip'; - -import { - htmlIdGenerator, - EuiComboBox, - EuiComboBoxOptionOption, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSelect, - EuiSwitch, -} from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import type { SearchResponse7 } from '../../../../common/types/es_client'; -import type { ResultsSearchQuery } from '../../data_frame_analytics/common/analytics'; - -import { useMlApiContext } from '../../contexts/kibana'; - -import { getProcessedFields } from '../data_grid'; -import { useCurrentEuiTheme } from '../color_range_legend'; - -import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; - -import { - getScatterplotMatrixVegaLiteSpec, - LegendType, - OUTLIER_SCORE_FIELD, -} from './scatterplot_matrix_vega_lite_spec'; - -import './scatterplot_matrix_view.scss'; - -const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; - -const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { - defaultMessage: 'On', -}); -const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { - defaultMessage: 'Off', -}); - -const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); - -export interface ScatterplotMatrixViewProps { - fields: string[]; - index: string; - resultsField?: string; - color?: string; - legendType?: LegendType; - searchQuery?: ResultsSearchQuery; -} - -export const ScatterplotMatrixView: FC = ({ - fields: allFields, - index, - resultsField, - color, - legendType, - searchQuery, -}) => { - const { esSearch } = useMlApiContext(); - - // dynamicSize is optionally used for outlier charts where the scatterplot marks - // are sized according to outlier_score - const [dynamicSize, setDynamicSize] = useState(false); - - // used to give the use the option to customize the fields used for the matrix axes - const [fields, setFields] = useState([]); - - useEffect(() => { - const defaultFields = - allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS - ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) - : allFields; - setFields(defaultFields); - }, [allFields]); - - // the amount of documents to be fetched - const [fetchSize, setFetchSize] = useState(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); - // flag to add a random score to the ES query to fetch documents - const [randomizeQuery, setRandomizeQuery] = useState(false); - - const [isLoading, setIsLoading] = useState(false); - - // contains the fetched documents and columns to be passed on to the Vega spec. - const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); - - // formats the array of field names for EuiComboBox - const fieldOptions = useMemo( - () => - allFields.map((d) => ({ - label: d, - })), - [allFields] - ); - - const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { - setFields(newFields.map((d) => d.label)); - }; - - const fetchSizeOnChange = (e: React.ChangeEvent) => { - setFetchSize( - Math.min( - Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), - SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE - ) - ); - }; - - const randomizeQueryOnChange = () => { - setRandomizeQuery(!randomizeQuery); - }; - - const dynamicSizeOnChange = () => { - setDynamicSize(!dynamicSize); - }; - - const { euiTheme } = useCurrentEuiTheme(); - - useEffect(() => { - async function fetchSplom(options: { didCancel: boolean }) { - setIsLoading(true); - try { - const queryFields = [ - ...fields, - ...(color !== undefined ? [color] : []), - ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), - ]; - - const queryFallback = searchQuery !== undefined ? searchQuery : { match_all: {} }; - const query = randomizeQuery - ? { - function_score: { - query: queryFallback, - random_score: { seed: 10, field: '_seq_no' }, - }, - } - : queryFallback; - - const resp: SearchResponse7 = await esSearch({ - index, - body: { - fields: queryFields, - _source: false, - query, - from: 0, - size: fetchSize, - }, - }); - - if (!options.didCancel) { - const items = resp.hits.hits.map((d) => - getProcessedFields(d.fields, (key: string) => - key.startsWith(`${resultsField}.feature_importance`) - ) - ); - - setSplom({ columns: fields, items }); - setIsLoading(false); - } - } catch (e) { - // TODO error handling - setIsLoading(false); - } - } - - const options = { didCancel: false }; - fetchSplom(options); - return () => { - options.didCancel = true; - }; - // stringify the fields array and search, otherwise the comparator will trigger on new but identical instances. - }, [fetchSize, JSON.stringify({ fields, searchQuery }), index, randomizeQuery, resultsField]); - - const htmlId = useMemo(() => htmlIdGenerator()(), []); - - useEffect(() => { - if (splom === undefined) { - return; - } - - const { items, columns } = splom; - - const values = - resultsField !== undefined - ? items - : items.map((d) => { - d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; - return d; - }); - - const vegaSpec = getScatterplotMatrixVegaLiteSpec( - values, - columns, - euiTheme, - resultsField, - color, - legendType, - dynamicSize - ); - - const vgSpec = compile(vegaSpec).spec; - - const view = new View(parse(vgSpec)) - .logLevel(Warn) - .renderer('canvas') - .tooltip(new Handler().call) - .initialize(`#${htmlId}`); - - view.runAsync(); // evaluate and render the view - }, [resultsField, splom, color, legendType, dynamicSize]); - - return ( - <> - {splom === undefined ? ( - - ) : ( - <> - - - - ({ - label: d, - }))} - onChange={fieldsOnChange} - isClearable={true} - data-test-subj="mlScatterplotMatrixFieldsComboBox" - /> - - - - - - - - - - - - - {resultsField !== undefined && legendType === undefined && ( - - - - - - )} - - -
    - - )} - - ); -}; - -// required for dynamic import using React.lazy() -// eslint-disable-next-line import/no-default-export -export default ScatterplotMatrixView; diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/common.ts b/x-pack/plugins/ml/public/application/components/vega_chart/common.ts new file mode 100644 index 0000000000000..79254788ce7a6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/common.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const LEGEND_TYPES = { + NOMINAL: 'nominal', + QUANTITATIVE: 'quantitative', +} as const; +export type LegendType = typeof LEGEND_TYPES[keyof typeof LEGEND_TYPES]; diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/index.ts b/x-pack/plugins/ml/public/application/components/vega_chart/index.ts new file mode 100644 index 0000000000000..f1d5c3ed4523b --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Make sure to only export the component we can lazy load here. +// Code from other files in this directory should be imported directly from the file, +// otherwise we break the bundling approach using lazy loading. +export { VegaChart } from './vega_chart'; diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx new file mode 100644 index 0000000000000..ab175908d9d79 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, Suspense } from 'react'; + +import { VegaChartLoading } from './vega_chart_loading'; +import type { VegaChartViewProps } from './vega_chart_view'; + +const VegaChartView = React.lazy(() => import('./vega_chart_view')); + +export const VegaChart: FC = (props) => ( + }> + + +); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx similarity index 91% rename from x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx rename to x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx index cdb4d99b041d5..8a5c1575f94d6 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; -export const ScatterplotMatrixLoading = () => { +export const VegaChartLoading = () => { return ( diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx new file mode 100644 index 0000000000000..7774def574b69 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useEffect, FC } from 'react'; + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import type { TopLevelSpec } from 'vega-lite/build-es5/vega-lite'; + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import { compile } from 'vega-lite/build-es5/vega-lite'; +import { parse, View, Warn } from 'vega'; +import { Handler } from 'vega-tooltip'; + +import { htmlIdGenerator } from '@elastic/eui'; + +export interface VegaChartViewProps { + vegaSpec: TopLevelSpec; +} + +export const VegaChartView: FC = ({ vegaSpec }) => { + const htmlId = useMemo(() => htmlIdGenerator()(), []); + + useEffect(() => { + const vgSpec = compile(vegaSpec).spec; + + const view = new View(parse(vgSpec)) + .logLevel(Warn) + .renderer('canvas') + .tooltip(new Handler().call) + .initialize(`#${htmlId}`); + + view.runAsync(); // evaluate and render the view + }, [vegaSpec]); + + return
    ; +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default VegaChartView; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 4f1799ed26f87..1c13177e44e7f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -154,11 +154,21 @@ export interface ConfusionMatrix { other_predicted_class_doc_count: number; } +export interface RocCurveItem { + fpr: number; + threshold: number; + tpr: number; +} + export interface ClassificationEvaluateResponse { classification: { - multiclass_confusion_matrix: { + multiclass_confusion_matrix?: { confusion_matrix: ConfusionMatrix[]; }; + auc_roc?: { + curve?: RocCurveItem[]; + value: number; + }; }; } @@ -244,7 +254,8 @@ export const isClassificationEvaluateResponse = ( return ( keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && - arg?.classification?.multiclass_confusion_matrix !== undefined + (arg?.classification?.multiclass_confusion_matrix !== undefined || + arg?.classification?.auc_roc !== undefined) ); }; @@ -422,7 +433,8 @@ export enum REGRESSION_STATS { interface EvaluateMetrics { classification: { - multiclass_confusion_matrix: object; + multiclass_confusion_matrix?: object; + auc_roc?: { include_curve: boolean; class_name: string }; }; regression: { r_squared: object; @@ -442,6 +454,8 @@ interface LoadEvalDataConfig { ignoreDefaultQuery?: boolean; jobType: DataFrameAnalysisConfigType; requiresKeyword?: boolean; + rocCurveClassName?: string; + includeMulticlassConfusionMatrix?: boolean; } export const loadEvalData = async ({ @@ -454,6 +468,8 @@ export const loadEvalData = async ({ ignoreDefaultQuery, jobType, requiresKeyword, + rocCurveClassName, + includeMulticlassConfusionMatrix = true, }: LoadEvalDataConfig) => { const results: LoadEvaluateResult = { success: false, eval: null, error: null }; const defaultPredictionField = `${dependentVariable}_prediction`; @@ -469,7 +485,10 @@ export const loadEvalData = async ({ const metrics: EvaluateMetrics = { classification: { - multiclass_confusion_matrix: {}, + ...(includeMulticlassConfusionMatrix ? { multiclass_confusion_matrix: {} } : {}), + ...(rocCurveClassName !== undefined + ? { auc_roc: { include_curve: true, class_name: rocCurveClassName } } + : {}), }, regression: { r_squared: {}, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts index a8b95a415ea53..2113f9385c5ef 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts @@ -9,7 +9,7 @@ import { ANALYSIS_CONFIG_TYPE } from './analytics'; import { AnalyticsJobType } from '../pages/analytics_management/hooks/use_create_analytics_form/state'; -import { LEGEND_TYPES } from '../../components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec'; +import { LEGEND_TYPES } from '../../components/vega_chart/common'; export const getScatterplotMatrixLegendType = (jobType: AnalyticsJobType | 'unknown') => { switch (jobType) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss index d1c507c5241d5..73ced778821cf 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss @@ -1,3 +1,6 @@ +/* Fixed width so we can align it with the padding of the AUC ROC chart. */ +$labelColumnWidth: 80px; + /* Workaround for EuiDataGrid within a Flex Layout, this tricks browsers treating the width as a px value instead of % @@ -6,7 +9,7 @@ width: 100%; } -.mlDataFrameAnalyticsClassification__confusionMatrix { +.mlDataFrameAnalyticsClassification__evaluateSectionContent { padding: 0 5%; } @@ -14,7 +17,7 @@ The following two classes are a workaround to avoid having EuiDataGrid in a flex layout and just uses a legacy approach for a two column layout so we don't break IE11. */ -.mlDataFrameAnalyticsClassification__confusionMatrix:after { +.mlDataFrameAnalyticsClassification__evaluateSectionContent:after { content: ''; display: table; clear: both; @@ -22,7 +25,7 @@ .mlDataFrameAnalyticsClassification__actualLabel { float: left; - width: 8%; + width: $labelColumnWidth; padding-top: $euiSize * 4; } @@ -32,7 +35,7 @@ .mlDataFrameAnalyticsClassification__dataGridMinWidth { float: left; min-width: 480px; - width: 92%; + width: calc(100% - #{$labelColumnWidth}); .euiDataGridRowCell--boolean { text-transform: none; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index b7dec4e5a435e..20866bf43a2f4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -21,26 +21,20 @@ import { EuiTitle, } from '@elastic/eui'; import { useMlKibana } from '../../../../../contexts/kibana'; + +// Separate imports for lazy loadable VegaChart and related code +import { VegaChart } from '../../../../../components/vega_chart'; +import { VegaChartLoading } from '../../../../../components/vega_chart/vega_chart_loading'; + import { ErrorCallout } from '../error_callout'; -import { - getDependentVar, - getPredictionFieldName, - loadEvalData, - loadDocsCount, - DataFrameAnalyticsConfig, -} from '../../../../common'; -import { isKeywordAndTextType } from '../../../../common/fields'; +import { getDependentVar, DataFrameAnalyticsConfig } from '../../../../common'; import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common'; -import { - isResultsSearchBoolQuery, - isClassificationEvaluateResponse, - ConfusionMatrix, - ResultsSearchQuery, - ANALYSIS_CONFIG_TYPE, -} from '../../../../common/analytics'; +import { ResultsSearchQuery } from '../../../../common/analytics'; import { ExpandableSection, HEADER_ITEMS_LOADING } from '../expandable_section'; +import { getRocCurveChartVegaLiteSpec } from './get_roc_curve_chart_vega_lite_spec'; + import { getColumnData, ACTUAL_CLASS_ID, @@ -48,6 +42,10 @@ import { getTrailingControlColumns, } from './column_data'; +import { isTrainingFilter } from './is_training_filter'; +import { useRocCurve } from './use_roc_curve'; +import { useConfusionMatrix } from './use_confusion_matrix'; + export interface EvaluatePanelProps { jobConfig: DataFrameAnalyticsConfig; jobStatus?: DataFrameTaskStateType; @@ -81,7 +79,7 @@ const trainingDatasetHelpText = i18n.translate( } ); -function getHelpText(dataSubsetTitle: string) { +function getHelpText(dataSubsetTitle: string): string { let helpText = entireDatasetHelpText; if (dataSubsetTitle === SUBSET_TITLE.TESTING) { helpText = testingDatasetHelpText; @@ -95,77 +93,36 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se const { services: { docLinks }, } = useMlKibana(); - const [isLoading, setIsLoading] = useState(false); - const [confusionMatrixData, setConfusionMatrixData] = useState([]); + const [columns, setColumns] = useState([]); const [columnsData, setColumnsData] = useState([]); const [showFullColumns, setShowFullColumns] = useState(false); const [popoverContents, setPopoverContents] = useState([]); - const [docsCount, setDocsCount] = useState(null); - const [error, setError] = useState(null); const [dataSubsetTitle, setDataSubsetTitle] = useState(SUBSET_TITLE.ENTIRE); // Column visibility - const [visibleColumns, setVisibleColumns] = useState(() => + const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }: { id: string }) => id) ); - const index = jobConfig.dest.index; - const dependentVariable = getDependentVar(jobConfig.analysis); - const predictionFieldName = getPredictionFieldName(jobConfig.analysis); - // default is 'ml' const resultsField = jobConfig.dest.results_field; - let requiresKeyword = false; + const isTraining = isTrainingFilter(searchQuery, resultsField); - const loadData = async ({ isTraining }: { isTraining: boolean | undefined }) => { - setIsLoading(true); - - try { - requiresKeyword = isKeywordAndTextType(dependentVariable); - } catch (e) { - // Additional error handling due to missing field type is handled by loadEvalData - console.error('Unable to load new field types', error); // eslint-disable-line no-console - } - - const evalData = await loadEvalData({ - isTraining, - index, - dependentVariable, - resultsField, - predictionFieldName, - searchQuery, - jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, - requiresKeyword, - }); - - const docsCountResp = await loadDocsCount({ - isTraining, - searchQuery, - resultsField, - destIndex: jobConfig.dest.index, - }); - - if ( - evalData.success === true && - evalData.eval && - isClassificationEvaluateResponse(evalData.eval) - ) { - const confusionMatrix = - evalData.eval?.classification?.multiclass_confusion_matrix?.confusion_matrix; - setError(null); - setConfusionMatrixData(confusionMatrix || []); - setIsLoading(false); - } else { - setIsLoading(false); - setConfusionMatrixData([]); - setError(evalData.error); - } + const { + confusionMatrixData, + docsCount, + error: errorConfusionMatrix, + isLoading: isLoadingConfusionMatrix, + } = useConfusionMatrix(jobConfig, searchQuery); - if (docsCountResp.success === true) { - setDocsCount(docsCountResp.docsCount); + useEffect(() => { + if (isTraining === undefined) { + setDataSubsetTitle(SUBSET_TITLE.ENTIRE); } else { - setDocsCount(null); + setDataSubsetTitle( + isTraining && isTraining === true ? SUBSET_TITLE.TRAINING : SUBSET_TITLE.TESTING + ); } - }; + }, [isTraining]); useEffect(() => { if (confusionMatrixData.length > 0) { @@ -198,48 +155,12 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } }, [confusionMatrixData]); - useEffect(() => { - let isTraining: boolean | undefined; - const query = - isResultsSearchBoolQuery(searchQuery) && (searchQuery.bool.should || searchQuery.bool.filter); - - if (query !== undefined && query !== false) { - for (let i = 0; i < query.length; i++) { - const clause = query[i]; - - if (clause.match && clause.match[`${resultsField}.is_training`] !== undefined) { - isTraining = clause.match[`${resultsField}.is_training`]; - break; - } else if ( - clause.bool && - (clause.bool.should !== undefined || clause.bool.filter !== undefined) - ) { - const innerQuery = clause.bool.should || clause.bool.filter; - if (innerQuery !== undefined) { - for (let j = 0; j < innerQuery.length; j++) { - const innerClause = innerQuery[j]; - if ( - innerClause.match && - innerClause.match[`${resultsField}.is_training`] !== undefined - ) { - isTraining = innerClause.match[`${resultsField}.is_training`]; - break; - } - } - } - } - } - } - if (isTraining === undefined) { - setDataSubsetTitle(SUBSET_TITLE.ENTIRE); - } else { - setDataSubsetTitle( - isTraining && isTraining === true ? SUBSET_TITLE.TRAINING : SUBSET_TITLE.TESTING - ); - } - - loadData({ isTraining }); - }, [JSON.stringify(searchQuery)]); + const { + rocCurveData, + classificationClasses, + error: errorRocCurve, + isLoading: isLoadingRocCurve, + } = useRocCurve(jobConfig, searchQuery, visibleColumns); const renderCellValue = ({ rowIndex, @@ -312,7 +233,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } headerItems={ - !isLoading + !isLoadingConfusionMatrix ? [ ...(jobStatus !== undefined ? [ @@ -348,94 +269,149 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } contentPadding={true} content={ - !isLoading ? ( - <> - {error !== null && } - {error === null && ( - <> - - - {getHelpText(dataSubsetTitle)} - - - - - - {/* BEGIN TABLE ELEMENTS */} - -
    -
    - - - -
    -
    - {columns.length > 0 && columnsData.length > 0 && ( - <> -
    - - - -
    - - + {!isLoadingConfusionMatrix ? ( + <> + {errorConfusionMatrix !== null && } + {errorConfusionMatrix === null && ( + <> + + + {getHelpText(dataSubsetTitle)} + + + + + + {/* BEGIN TABLE ELEMENTS */} + +
    +
    + + - - )} + +
    +
    + {columns.length > 0 && columnsData.length > 0 && ( + <> +
    + + + +
    + + + + )} +
    -
    - - )} - {/* END TABLE ELEMENTS */} - - ) : null + {/* END TABLE ELEMENTS */} + + )} + + ) : null} + {/* AUC ROC Chart */} + + + + + + + + + + + + {Array.isArray(errorRocCurve) && ( + + {errorRocCurve.map((e) => ( + <> + {e} +
    + + ))} + + } + /> + )} + {!isLoadingRocCurve && errorRocCurve === null && rocCurveData.length > 0 && ( +
    + +
    + )} + {isLoadingRocCurve && } + } /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx new file mode 100644 index 0000000000000..b9e9c5720e5aa --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import type { TopLevelSpec } from 'vega-lite/build-es5/vega-lite'; + +import { euiPaletteColorBlind, euiPaletteGray } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { LEGEND_TYPES } from '../../../../../components/vega_chart/common'; + +import { RocCurveItem } from '../../../../common/analytics'; + +const GRAY = euiPaletteGray(1)[0]; +const BASELINE = 'baseline'; +const SIZE = 300; + +// returns a custom color range that includes gray for the baseline +function getColorRangeNominal(classificationClasses: string[]) { + const legendItems = [...classificationClasses, BASELINE].sort(); + const baselineIndex = legendItems.indexOf(BASELINE); + + const colorRangeNominal = euiPaletteColorBlind({ rotations: 2 }).slice( + 0, + classificationClasses.length + ); + + colorRangeNominal.splice(baselineIndex, 0, GRAY); + + return colorRangeNominal; +} + +export interface RocCurveDataRow extends RocCurveItem { + class_name: string; +} + +export const getRocCurveChartVegaLiteSpec = ( + classificationClasses: string[], + data: RocCurveDataRow[], + legendTitle: string +): TopLevelSpec => { + // we append two rows which make up the data for the diagonal baseline + data.push({ tpr: 0, fpr: 0, threshold: 1, class_name: BASELINE }); + data.push({ tpr: 1, fpr: 1, threshold: 1, class_name: BASELINE }); + + const colorRangeNominal = getColorRangeNominal(classificationClasses); + + return { + $schema: 'https://vega.github.io/schema/vega-lite/v4.8.1.json', + // Left padding of 45px to align the left axis of the chart with the confusion matrix above. + padding: { left: 45, top: 0, right: 0, bottom: 0 }, + config: { + legend: { + orient: 'right', + }, + view: { + continuousHeight: SIZE, + continuousWidth: SIZE, + }, + }, + data: { + name: 'roc-curve-data', + }, + datasets: { + 'roc-curve-data': data, + }, + encoding: { + color: { + field: 'class_name', + type: LEGEND_TYPES.NOMINAL, + scale: { + range: colorRangeNominal, + }, + legend: { + title: legendTitle, + }, + }, + size: { + value: 2, + }, + strokeDash: { + condition: { + test: `(datum.class_name === '${BASELINE}')`, + value: [5, 5], + }, + value: [0], + }, + x: { + field: 'fpr', + sort: null, + title: i18n.translate('xpack.ml.dataframe.analytics.rocChartSpec.xAxisTitle', { + defaultMessage: 'False Positive Rate (FPR)', + }), + type: 'quantitative', + axis: { + tickColor: GRAY, + labelColor: GRAY, + domainColor: GRAY, + titleColor: GRAY, + }, + }, + y: { + field: 'tpr', + title: i18n.translate('xpack.ml.dataframe.analytics.rocChartSpec.yAxisTitle', { + defaultMessage: 'True Positive Rate (TPR) (a.k.a Recall)', + }), + type: 'quantitative', + axis: { + tickColor: GRAY, + labelColor: GRAY, + domainColor: GRAY, + titleColor: GRAY, + }, + }, + tooltip: [ + { type: LEGEND_TYPES.NOMINAL, field: 'class_name' }, + { type: LEGEND_TYPES.QUANTITATIVE, field: 'fpr' }, + { type: LEGEND_TYPES.QUANTITATIVE, field: 'tpr' }, + ], + }, + height: SIZE, + width: SIZE, + mark: 'line', + }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts new file mode 100644 index 0000000000000..21203f85bbe84 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isResultsSearchBoolQuery, ResultsSearchQuery } from '../../../../common/analytics'; + +export type IsTraining = boolean | undefined; + +export function isTrainingFilter( + searchQuery: ResultsSearchQuery, + resultsField: string +): IsTraining { + let isTraining: IsTraining; + const query = + isResultsSearchBoolQuery(searchQuery) && (searchQuery.bool.should || searchQuery.bool.filter); + + if (query !== undefined && query !== false) { + for (let i = 0; i < query.length; i++) { + const clause = query[i]; + + if (clause.match && clause.match[`${resultsField}.is_training`] !== undefined) { + isTraining = clause.match[`${resultsField}.is_training`]; + break; + } else if ( + clause.bool && + (clause.bool.should !== undefined || clause.bool.filter !== undefined) + ) { + const innerQuery = clause.bool.should || clause.bool.filter; + if (innerQuery !== undefined) { + for (let j = 0; j < innerQuery.length; j++) { + const innerClause = innerQuery[j]; + if ( + innerClause.match && + innerClause.match[`${resultsField}.is_training`] !== undefined + ) { + isTraining = innerClause.match[`${resultsField}.is_training`]; + break; + } + } + } + } + } + } + + return isTraining; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts new file mode 100644 index 0000000000000..be44a8e36ed00 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; + +import { + isClassificationEvaluateResponse, + ConfusionMatrix, + ResultsSearchQuery, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common/analytics'; +import { isKeywordAndTextType } from '../../../../common/fields'; + +import { + getDependentVar, + getPredictionFieldName, + loadEvalData, + loadDocsCount, + DataFrameAnalyticsConfig, +} from '../../../../common'; + +import { isTrainingFilter } from './is_training_filter'; + +export const useConfusionMatrix = ( + jobConfig: DataFrameAnalyticsConfig, + searchQuery: ResultsSearchQuery +) => { + const [confusionMatrixData, setConfusionMatrixData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [docsCount, setDocsCount] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadConfusionMatrixData() { + setIsLoading(true); + + let requiresKeyword = false; + const dependentVariable = getDependentVar(jobConfig.analysis); + const resultsField = jobConfig.dest.results_field; + const isTraining = isTrainingFilter(searchQuery, resultsField); + + try { + requiresKeyword = isKeywordAndTextType(dependentVariable); + } catch (e) { + // Additional error handling due to missing field type is handled by loadEvalData + console.error('Unable to load new field types', e); // eslint-disable-line no-console + } + + const evalData = await loadEvalData({ + isTraining, + index: jobConfig.dest.index, + dependentVariable, + resultsField, + predictionFieldName: getPredictionFieldName(jobConfig.analysis), + searchQuery, + jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + requiresKeyword, + }); + + const docsCountResp = await loadDocsCount({ + isTraining, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, + }); + + if ( + evalData.success === true && + evalData.eval && + isClassificationEvaluateResponse(evalData.eval) + ) { + const confusionMatrix = + evalData.eval?.classification?.multiclass_confusion_matrix?.confusion_matrix; + setError(null); + setConfusionMatrixData(confusionMatrix || []); + setIsLoading(false); + } else { + setIsLoading(false); + setConfusionMatrixData([]); + setError(evalData.error); + } + + if (docsCountResp.success === true) { + setDocsCount(docsCountResp.docsCount); + } else { + setDocsCount(null); + } + } + + loadConfusionMatrixData(); + }, [JSON.stringify([jobConfig, searchQuery])]); + + return { confusionMatrixData, docsCount, error, isLoading }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts new file mode 100644 index 0000000000000..8cdb6f86ebdda --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; + +import { + isClassificationEvaluateResponse, + ResultsSearchQuery, + RocCurveItem, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common/analytics'; +import { isKeywordAndTextType } from '../../../../common/fields'; + +import { + getDependentVar, + getPredictionFieldName, + loadEvalData, + DataFrameAnalyticsConfig, +} from '../../../../common'; + +import { ACTUAL_CLASS_ID, OTHER_CLASS_ID } from './column_data'; + +import { isTrainingFilter } from './is_training_filter'; + +interface RocCurveDataRow extends RocCurveItem { + class_name: string; +} + +export const useRocCurve = ( + jobConfig: DataFrameAnalyticsConfig, + searchQuery: ResultsSearchQuery, + visibleColumns: string[] +) => { + const classificationClasses = visibleColumns.filter( + (d) => d !== ACTUAL_CLASS_ID && d !== OTHER_CLASS_ID + ); + + const [rocCurveData, setRocCurveData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadRocCurveData() { + setIsLoading(true); + + const dependentVariable = getDependentVar(jobConfig.analysis); + const resultsField = jobConfig.dest.results_field; + + const newRocCurveData: RocCurveDataRow[] = []; + + let requiresKeyword = false; + const errors: string[] = []; + + try { + requiresKeyword = isKeywordAndTextType(dependentVariable); + } catch (e) { + // Additional error handling due to missing field type is handled by loadEvalData + console.error('Unable to load new field types', e); // eslint-disable-line no-console + } + + for (let i = 0; i < classificationClasses.length; i++) { + const rocCurveClassName = classificationClasses[i]; + const evalData = await loadEvalData({ + isTraining: isTrainingFilter(searchQuery, resultsField), + index: jobConfig.dest.index, + dependentVariable, + resultsField, + predictionFieldName: getPredictionFieldName(jobConfig.analysis), + searchQuery, + jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + requiresKeyword, + rocCurveClassName, + includeMulticlassConfusionMatrix: false, + }); + + if ( + evalData.success === true && + evalData.eval && + isClassificationEvaluateResponse(evalData.eval) + ) { + const auc = evalData.eval?.classification?.auc_roc?.value || 0; + const rocCurveDataForClass = (evalData.eval?.classification?.auc_roc?.curve || []).map( + (d) => ({ + class_name: `${rocCurveClassName} (AUC: ${Math.round(auc * 100000) / 100000})`, + ...d, + }) + ); + newRocCurveData.push(...rocCurveDataForClass); + } else if (evalData.error !== null) { + errors.push(evalData.error); + } + } + + setError(errors.length > 0 ? errors : null); + setRocCurveData(newRocCurveData); + setIsLoading(false); + } + + loadRocCurveData(); + }, [JSON.stringify([jobConfig, searchQuery, visibleColumns])]); + + return { rocCurveData, classificationClasses, error, isLoading }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx index d18e5b55794b5..81f5e53570809 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiCallOut } from '@elastic/eui'; interface Props { - error: string; + error: string | JSX.Element; } export const ErrorCallout: FC = ({ error }) => { @@ -26,7 +26,7 @@ export const ErrorCallout: FC = ({ error }) => { ); // Job was created but not started so the destination index has not been created - if (error.includes('index_not_found')) { + if (typeof error === 'string' && error.includes('index_not_found')) { errorCallout = ( = ({ error }) => {

    ); - } else if (error.includes('No documents found')) { + } else if (typeof error === 'string' && error.includes('No documents found')) { // Job was started but no results have been written yet errorCallout = ( = ({ error }) => {

    ); - } else if (error.includes('userProvidedQueryBuilder')) { + } else if (typeof error === 'string' && error.includes('userProvidedQueryBuilder')) { // query bar syntax is incorrect errorCallout = ( = ({ )} + {isLoadingJobConfig === true && jobConfig === undefined && } + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( + + )} + {isLoadingJobConfig === true && jobConfig !== undefined && totalFeatureImportance === undefined && } @@ -191,10 +196,7 @@ export const ExplorationPageWrapper: FC = ({ )} - {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( - - )} + {isLoadingJobConfig === true && jobConfig === undefined && } {isLoadingJobConfig === false && diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index d4b07203e8109..d3487078fd114 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -3183,6 +3183,19 @@ } } }, + "search-session": { + "properties": { + "transientCount": { + "type": "long" + }, + "persistedCount": { + "type": "long" + }, + "totalCount": { + "type": "long" + } + } + }, "security_solution": { "properties": { "detections": { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4d94539a514d1..e24db7d2cf9c3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9467,7 +9467,6 @@ "xpack.indexLifecycleMgmt.editPolicy.forcemerge.numberOfSegmentsRequiredError": "セグメント数の評価が必要です。", "xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage": "このページのエラーを修正してください。", "xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto": "リクエストを非表示", - "xpack.indexLifecycleMgmt.editPolicy.hotPhase.enableRolloverTipContent": "現在のインデックスが定義された条件のいずれかを満たすときに、新しいインデックスにロールオーバーします。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText": "詳細", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDefaultsTipContent": "インデックスが 30 日経過するか、50 GB に達したらロールオーバーします。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDescriptionMessage": "効率的なストレージと高いパフォーマンスのための時系列データの自動ロールアウト。", @@ -11155,7 +11154,6 @@ "xpack.lens.editLayerSettingsChartType": "レイヤー設定を編集、{chartType}", "xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました", "xpack.lens.editorFrame.colorIndicatorLabel": "このディメンションの色:{hex}", - "xpack.lens.editorFrame.configurationFailure": "無効な構成です", "xpack.lens.editorFrame.configurationFailureMoreErrors": " +{errors} {errors, plural, other {エラー}}", "xpack.lens.editorFrame.dataFailure": "データの読み込み中にエラーが発生しました。", "xpack.lens.editorFrame.emptyWorkspace": "開始するにはここにフィールドをドロップしてください", @@ -11327,9 +11325,7 @@ "xpack.lens.indexPattern.terms.missingLabel": "(欠落値)", "xpack.lens.indexPattern.terms.orderAlphabetical": "アルファベット順", "xpack.lens.indexPattern.terms.orderAscending": "昇順", - "xpack.lens.indexPattern.terms.orderBy": "並び順", "xpack.lens.indexPattern.terms.orderDescending": "降順", - "xpack.lens.indexPattern.terms.orderDirection": "全体的な方向", "xpack.lens.indexPattern.terms.otherBucketDescription": "他の値を「その他」としてグループ化", "xpack.lens.indexPattern.terms.otherLabel": "その他", "xpack.lens.indexPattern.terms.size": "値の数", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a35d4c67dde00..5d583971552b5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9491,7 +9491,6 @@ "xpack.indexLifecycleMgmt.editPolicy.forcemerge.numberOfSegmentsRequiredError": "必须指定分段数的值。", "xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage": "请修复此页面上的错误。", "xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto": "隐藏请求", - "xpack.indexLifecycleMgmt.editPolicy.hotPhase.enableRolloverTipContent": "在当前索引满足定义的条件之一时,滚动更新到新索引。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText": "了解详情", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDefaultsTipContent": "当索引已存在 30 天或达到 50 GB 时滚动更新。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDescriptionMessage": "自动滚动更新时间序列数据,以实现高效存储和更高性能。", @@ -11183,7 +11182,6 @@ "xpack.lens.editLayerSettingsChartType": "编辑图层设置 {chartType}", "xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误", "xpack.lens.editorFrame.colorIndicatorLabel": "此维度的颜色:{hex}", - "xpack.lens.editorFrame.configurationFailure": "配置无效", "xpack.lens.editorFrame.configurationFailureMoreErrors": " +{errors} 个{errors, plural, other {错误}}", "xpack.lens.editorFrame.dataFailure": "加载数据时出错。", "xpack.lens.editorFrame.emptyWorkspace": "将一些字段拖放到此处以开始", @@ -11355,9 +11353,7 @@ "xpack.lens.indexPattern.terms.missingLabel": "(缺失值)", "xpack.lens.indexPattern.terms.orderAlphabetical": "按字母顺序", "xpack.lens.indexPattern.terms.orderAscending": "升序", - "xpack.lens.indexPattern.terms.orderBy": "排序依据", "xpack.lens.indexPattern.terms.orderDescending": "降序", - "xpack.lens.indexPattern.terms.orderDirection": "排序方向", "xpack.lens.indexPattern.terms.otherBucketDescription": "将其他值分组为“其他”", "xpack.lens.indexPattern.terms.otherLabel": "其他", "xpack.lens.indexPattern.terms.size": "值数目", diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts index c4b3a4ed0adcf..8e5da7c56bb64 100644 --- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts @@ -277,7 +277,7 @@ export default ({ getService }: FtrProviderContext) => { jobId: 'pf7_log-entry-categories-count', jobState: JOB_STATE.CLOSED, datafeedState: DATAFEED_STATE.STOPPED, - modelMemoryLimit: '26mb', + modelMemoryLimit: '41mb', }, ], searches: [] as string[], @@ -713,8 +713,7 @@ export default ({ getService }: FtrProviderContext) => { return successObjects; } - // blocks ES snapshot promotion: https://github.com/elastic/kibana/issues/91224 - describe.skip('module setup', function () { + describe('module setup', function () { before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); }); diff --git a/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts b/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts index 3d76513b8379d..f79885246b0ac 100644 --- a/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts +++ b/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts @@ -20,7 +20,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('uncommon_processes', () => { + // FLAKY: https://github.com/elastic/kibana/issues/90416 + describe.skip('uncommon_processes', () => { before(() => esArchiver.load('auditbeat/hosts')); after(() => esArchiver.unload('auditbeat/hosts')); diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index 5cbd5dff45e1e..0d2db53ba73af 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -194,5 +194,39 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.filterEmbeddableNames('lnsPieVis'); await find.existsByLinkText('lnsPieVis'); }); + + it('should show validation messages if any error appears', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + + await dashboardAddPanel.clickCreateNewLink(); + await dashboardAddPanel.clickVisType('lens'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'moving_average', + keepOpen: true, + }); + await PageObjects.lens.configureReference({ + operation: 'sum', + field: 'bytes', + }); + await PageObjects.lens.closeDimensionEditor(); + + // remove the x dimension to trigger the validation error + await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel'); + await PageObjects.lens.saveAndReturn(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('embeddable-lens-failure'); + }); }); } diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index a86a67d7c8d0d..6ca13b232e11a 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -571,15 +571,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.goToTimeRange(); await PageObjects.lens.switchToVisualization('lnsDatatable'); // Sort by number - await PageObjects.lens.changeTableSortingBy(2, 'asc'); + await PageObjects.lens.changeTableSortingBy(2, 'ascending'); await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDatatableCellText(0, 2)).to.eql('17,246'); // Now sort by IP - await PageObjects.lens.changeTableSortingBy(0, 'asc'); + await PageObjects.lens.changeTableSortingBy(0, 'ascending'); await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('78.83.247.30'); // Change the sorting - await PageObjects.lens.changeTableSortingBy(0, 'desc'); + await PageObjects.lens.changeTableSortingBy(0, 'descending'); await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('169.228.188.120'); // Remove the sorting diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts index 3f9cdf06da8ab..211669e75dc3f 100644 --- a/x-pack/test/functional/apps/lens/table.ts +++ b/x-pack/test/functional/apps/lens/table.ts @@ -22,15 +22,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.goToTimeRange(); await PageObjects.lens.switchToVisualization('lnsDatatable'); // Sort by number - await PageObjects.lens.changeTableSortingBy(2, 'asc'); + await PageObjects.lens.changeTableSortingBy(2, 'ascending'); await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDatatableCellText(0, 2)).to.eql('17,246'); // Now sort by IP - await PageObjects.lens.changeTableSortingBy(0, 'asc'); + await PageObjects.lens.changeTableSortingBy(0, 'ascending'); await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('78.83.247.30'); // Change the sorting - await PageObjects.lens.changeTableSortingBy(0, 'desc'); + await PageObjects.lens.changeTableSortingBy(0, 'descending'); await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('169.228.188.120'); // Remove the sorting diff --git a/x-pack/test/functional/apps/maps/mvt_scaling.js b/x-pack/test/functional/apps/maps/mvt_scaling.js index c7d711e9fbe3c..ba3cdf33ae24e 100644 --- a/x-pack/test/functional/apps/maps/mvt_scaling.js +++ b/x-pack/test/functional/apps/maps/mvt_scaling.js @@ -30,7 +30,7 @@ export default function ({ getPageObjects, getService }) { //Source should be correct expect(mapboxStyle.sources[VECTOR_SOURCE_ID].tiles[0]).to.equal( - '/api/maps/mvt/getTile?x={x}&y={y}&z={z}&geometryFieldName=geometry&index=geo_shapes*&requestBody=(_source:(includes:!(geometry,prop1)),docvalue_fields:!(prop1),query:(bool:(filter:!((match_all:())),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(geometry,prop1))&geoFieldType=geo_shape' + '/api/maps/mvt/getTile?x={x}&y={y}&z={z}&geometryFieldName=geometry&index=geo_shapes*&requestBody=(_source:!(geometry),docvalue_fields:!(prop1),query:(bool:(filter:!((match_all:())),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(geometry,prop1))&geoFieldType=geo_shape' ); //Should correctly load meta for style-rule (sigma is set to 1, opacity to 1) diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 59f1775bb2117..1d67408b73360 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -41,6 +41,15 @@ export default function ({ getService }: FtrProviderContext) { modelMemory: '60mb', createIndexPattern: true, expected: { + rocCurveColorState: [ + // background + { key: '#FFFFFF', value: 93 }, + // tick/grid/axis + { key: '#98A2B3', value: 1 }, + { key: '#DDDDDD', value: 3 }, + // line + { key: '#6092C0', value: 1 }, + ], scatterplotMatrixColorStats: [ // background { key: '#000000', value: 94 }, @@ -102,7 +111,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); await ml.testExecution.logTestStep('displays the scatterplot matrix'); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', testData.expected.scatterplotMatrixColorStats ); @@ -221,11 +230,15 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the results view for created job'); await ml.dataFrameAnalyticsTable.openResultsView(testData.jobId); await ml.dataFrameAnalyticsResults.assertClassificationEvaluatePanelElementsExists(); + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( + 'mlDFAnalyticsClassificationExplorationRocCurveChart', + testData.expected.rocCurveColorState + ); await ml.dataFrameAnalyticsResults.assertClassificationTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlDFExpandableSection-splom', testData.expected.scatterplotMatrixColorStats ); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 02535f158ee63..8b291fa36867a 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -128,7 +128,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); await ml.testExecution.logTestStep('displays the scatterplot matrix'); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', testData.expected.scatterplotMatrixColorStatsWizard ); @@ -249,7 +249,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertOutlierTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlDFExpandableSection-splom', testData.expected.scatterplotMatrixColorStatsResults ); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index f41944e3409d7..4ce5d5b352e14 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -101,7 +101,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); await ml.testExecution.logTestStep('displays the scatterplot matrix'); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', testData.expected.scatterplotMatrixColorStats ); @@ -224,7 +224,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); - await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + await ml.dataFrameAnalyticsCanvasElement.assertCanvasElement( 'mlDFExpandableSection-splom', testData.expected.scatterplotMatrixColorStats ); diff --git a/x-pack/test/functional/es_archives/ml/ecommerce/data.json.gz b/x-pack/test/functional/es_archives/ml/ecommerce/data.json.gz index 75fbf6fcdb845..071622842c8e8 100644 Binary files a/x-pack/test/functional/es_archives/ml/ecommerce/data.json.gz and b/x-pack/test/functional/es_archives/ml/ecommerce/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/ml/module_sample_ecommerce/data.json.gz b/x-pack/test/functional/es_archives/ml/module_sample_ecommerce/data.json.gz index 76357734a94aa..87cf76184c5d2 100644 Binary files a/x-pack/test/functional/es_archives/ml/module_sample_ecommerce/data.json.gz and b/x-pack/test/functional/es_archives/ml/module_sample_ecommerce/data.json.gz differ diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index dcb730f77725d..d9ec9ca5d3f62 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -621,7 +621,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont ); }, - async changeTableSortingBy(colIndex = 0, direction: 'none' | 'asc' | 'desc') { + async changeTableSortingBy(colIndex = 0, direction: 'none' | 'ascending' | 'descending') { const el = await this.getDatatableHeader(colIndex); await el.click(); let buttonEl; diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts b/x-pack/test/functional/services/ml/data_frame_analytics_canvas_element.ts similarity index 72% rename from x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts rename to x-pack/test/functional/services/ml/data_frame_analytics_canvas_element.ts index 39b387e2de650..a354e0723d377 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_canvas_element.ts @@ -9,14 +9,14 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -export function MachineLearningDataFrameAnalyticsScatterplotProvider({ +export function MachineLearningDataFrameAnalyticsCanvasElementProvider({ getService, }: FtrProviderContext) { const canvasElement = getService('canvasElement'); const testSubjects = getService('testSubjects'); - return new (class AnalyticsScatterplot { - public async assertScatterplotMatrix( + return new (class AnalyticsCanvasElement { + public async assertCanvasElement( dataTestSubj: string, expectedColorStats: Array<{ key: string; @@ -24,16 +24,15 @@ export function MachineLearningDataFrameAnalyticsScatterplotProvider({ }> ) { await testSubjects.existOrFail(dataTestSubj); - await testSubjects.existOrFail('mlScatterplotMatrix'); const actualColorStats = await canvasElement.getColorStats( - `[data-test-subj="mlScatterplotMatrix"] canvas`, + `[data-test-subj="${dataTestSubj}"] canvas`, expectedColorStats, 1 ); expect(actualColorStats.every((d) => d.withinTolerance)).to.eql( true, - `Color stats for scatterplot matrix should be within tolerance. Expected: '${JSON.stringify( + `Color stats for canvas element should be within tolerance. Expected: '${JSON.stringify( expectedColorStats )}' (got '${JSON.stringify(actualColorStats)}')` ); diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts index b6aba13054f75..c08e13cedaaa5 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts @@ -32,6 +32,7 @@ export function MachineLearningDataFrameAnalyticsResultsProvider({ async assertClassificationEvaluatePanelElementsExists() { await testSubjects.existOrFail('mlDFExpandableSection-ClassificationEvaluation'); await testSubjects.existOrFail('mlDFAnalyticsClassificationExplorationConfusionMatrix'); + await testSubjects.existOrFail('mlDFAnalyticsClassificationExplorationRocCurveChart'); }, async assertClassificationTablePanelExists() { diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 91d009316cf9e..ceee1ba7dc1ac 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -18,7 +18,7 @@ import { MachineLearningDataFrameAnalyticsProvider } from './data_frame_analytic import { MachineLearningDataFrameAnalyticsCreationProvider } from './data_frame_analytics_creation'; import { MachineLearningDataFrameAnalyticsEditProvider } from './data_frame_analytics_edit'; import { MachineLearningDataFrameAnalyticsResultsProvider } from './data_frame_analytics_results'; -import { MachineLearningDataFrameAnalyticsScatterplotProvider } from './data_frame_analytics_scatterplot'; +import { MachineLearningDataFrameAnalyticsCanvasElementProvider } from './data_frame_analytics_canvas_element'; import { MachineLearningDataFrameAnalyticsMapProvider } from './data_frame_analytics_map'; import { MachineLearningDataFrameAnalyticsTableProvider } from './data_frame_analytics_table'; import { MachineLearningDataVisualizerProvider } from './data_visualizer'; @@ -66,7 +66,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataFrameAnalyticsResults = MachineLearningDataFrameAnalyticsResultsProvider(context); const dataFrameAnalyticsMap = MachineLearningDataFrameAnalyticsMapProvider(context); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); - const dataFrameAnalyticsScatterplot = MachineLearningDataFrameAnalyticsScatterplotProvider( + const dataFrameAnalyticsCanvasElement = MachineLearningDataFrameAnalyticsCanvasElementProvider( context ); @@ -113,7 +113,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { dataFrameAnalyticsResults, dataFrameAnalyticsMap, dataFrameAnalyticsTable, - dataFrameAnalyticsScatterplot, + dataFrameAnalyticsCanvasElement, dataVisualizer, dataVisualizerFileBased, dataVisualizerIndexBased,