diff --git a/api_docs/file_upload.json b/api_docs/file_upload.json index 17c15d184a6ba..350bd1aa067f3 100644 --- a/api_docs/file_upload.json +++ b/api_docs/file_upload.json @@ -372,7 +372,7 @@ "type": "Function", "label": "import", "signature": [ - "(id: string, index: string, pipelineId: string, setImportProgress: (progress: number) => void) => Promise<", + "(id: string, index: string, pipelineId: string | undefined, setImportProgress: (progress: number) => void) => Promise<", { "pluginId": "fileUpload", "scope": "public", @@ -413,9 +413,9 @@ { "type": "string", "label": "pipelineId", - "isRequired": true, + "isRequired": false, "signature": [ - "string" + "string | undefined" ], "description": [], "source": { diff --git a/docs/api/saved-objects/import.asciidoc b/docs/api/saved-objects/import.asciidoc index 4df2f07bfcf41..b69180e42d05f 100644 --- a/docs/api/saved-objects/import.asciidoc +++ b/docs/api/saved-objects/import.asciidoc @@ -6,6 +6,18 @@ experimental[] Create sets of {kib} saved objects from a file created by the export API. +==== Compatibility across versions +Saved objects can only be imported into the same version, a newer minor on the same major or the next major. Exported saved objects are not backwards compatible and cannot be imported into an older version of {kib}. See the table below for compatibility examples: + +|======= +| Exporting version | Importing version | Compatible? +| 6.7.0 | 6.8.1 | Yes +| 6.8.1 | 7.3.0 | Yes +| 7.3.0 | 7.11.1 | Yes +| 7.11.1 | 7.6.0 | No +| 6.8.1 | 8.0.0 | No +|======= + [[saved-objects-api-import-request]] ==== Request diff --git a/docs/management/managing-saved-objects.asciidoc b/docs/management/managing-saved-objects.asciidoc index 9e26abca115fc..d37c6f1920bda 100644 --- a/docs/management/managing-saved-objects.asciidoc +++ b/docs/management/managing-saved-objects.asciidoc @@ -43,6 +43,21 @@ have multiple environments for development and production. Import and export also work well when you have a large number of objects to update and want to batch the process. +[float] +==== Compatibility across versions + +With each release, {kib} introduces changes to the way saved objects are stored. When importing a saved object, {kib} will run the necessary migrations to ensure that the imported saved objects are compatible with the current version. + +However, saved objects can only be imported into the same version, a newer minor on the same major or the next major. Exported saved objects are not backwards compatible and cannot be imported into an older version of {kib}. See the table below for compatibility examples: + +|======= +| Exporting version | Importing version | Compatible? +| 6.7.0 | 6.8.1 | Yes +| 6.8.1 | 7.3.0 | Yes +| 7.3.0 | 7.11.1 | Yes +| 7.11.1 | 7.6.0 | No +| 6.8.1 | 8.0.0 | No +|======= [float] ==== Import diff --git a/package.json b/package.json index 2c8d5cd395cd4..cd1eaa660292e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "author": "Rashid Khan ", "scripts": { + "bazel": "bazel", "preinstall": "node ./preinstall_check", "kbn": "node scripts/kbn", "es": "node scripts/es", diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/tabs/tabs.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/tabs/tabs.tsx index 7771c5d54f415..4d99bd504cd0f 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/tabs/tabs.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/tabs/tabs.tsx @@ -178,7 +178,7 @@ export function Tabs({ /> - openFieldEditor()}> + openFieldEditor()} data-test-subj="addField"> {addFieldButtonLabel} diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js new file mode 100644 index 0000000000000..4b3533f20c8dc --- /dev/null +++ b/test/functional/apps/management/_runtime_fields.js @@ -0,0 +1,52 @@ +/* + * 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'; + +export default function ({ getService, getPageObjects }) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const log = getService('log'); + const browser = getService('browser'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['settings']); + + describe('runtime fields', function () { + this.tags(['skipFirefox']); + + before(async function () { + await browser.setWindowSize(1200, 800); + await esArchiver.load('discover'); + // delete .kibana index and then wait for Kibana to re-create it + await kibanaServer.uiSettings.replace({}); + await kibanaServer.uiSettings.update({}); + }); + + after(async function afterAll() { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaIndexPatterns(); + await PageObjects.settings.removeLogstashIndexPatternIfExist(); + }); + + describe('create runtime field', function describeIndexTests() { + const fieldName = 'atest'; + + it('should create runtime field', async function () { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaIndexPatterns(); + await PageObjects.settings.clickIndexPatternLogstash(); + const startingCount = parseInt(await PageObjects.settings.getFieldsTabCount()); + await log.debug('add runtime field'); + await PageObjects.settings.addRuntimeField(fieldName, 'Keyword', "emit('hello world')"); + await retry.try(async function () { + expect(parseInt(await PageObjects.settings.getFieldsTabCount())).to.be(startingCount + 1); + }); + }); + }); + }); +} diff --git a/test/functional/apps/management/index.ts b/test/functional/apps/management/index.ts index d31245b5492d1..fcb4e49dc7548 100644 --- a/test/functional/apps/management/index.ts +++ b/test/functional/apps/management/index.ts @@ -33,6 +33,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_mgmt_import_saved_objects')); loadTestFile(require.resolve('./_index_patterns_empty')); loadTestFile(require.resolve('./_scripted_fields')); + loadTestFile(require.resolve('./_runtime_fields')); }); describe('', function () { diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 0a1eaabdf49e8..4151a8c1a1893 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -491,6 +491,58 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider await this.clickSaveScriptedField(); } + async addRuntimeField(name: string, type: string, script: string) { + await this.clickAddField(); + await this.setFieldName(name); + await this.setFieldType(type); + if (script) { + await this.setFieldScript(script); + } + await this.clickSaveField(); + await this.closeIndexPatternFieldEditor(); + } + + async closeIndexPatternFieldEditor() { + await retry.waitFor('field editor flyout to close', async () => { + return !(await testSubjects.exists('euiFlyoutCloseButton')); + }); + } + + async clickAddField() { + log.debug('click Add Field'); + await testSubjects.click('addField'); + } + + async clickSaveField() { + log.debug('click Save'); + await testSubjects.click('fieldSaveButton'); + } + + async setFieldName(name: string) { + log.debug('set field name = ' + name); + await testSubjects.setValue('nameField', name); + } + + async setFieldType(type: string) { + log.debug('set type = ' + type); + await testSubjects.setValue('typeField', type); + } + + async setFieldScript(script: string) { + log.debug('set script = ' + script); + const formatRow = await testSubjects.find('valueRow'); + const formatRowToggle = ( + await formatRow.findAllByCssSelector('[data-test-subj="toggle"]') + )[0]; + + await formatRowToggle.click(); + const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; + retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); + const monacoTextArea = await getMonacoTextArea(); + await monacoTextArea.focus(); + browser.pressKeys(script); + } + async clickAddScriptedField() { log.debug('click Add Scripted Field'); await testSubjects.click('addScriptedFieldLink'); diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 857f6037c53c8..5ccf7c99ba341 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -81,3 +81,5 @@ export const JSON_HEADER = { }; export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode'; + +export const ENTERPRISE_SEARCH_KIBANA_COOKIE = '_enterprise_search'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx index 22498bbc50c21..9c47378302890 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -63,6 +63,15 @@ describe('RoleMappingsTable', () => { expect(wrapper.find(EuiTableRow)).toHaveLength(0); }); + it('handles input change with special chars', () => { + const wrapper = shallow(); + const input = wrapper.find(EuiFieldSearch); + const value = '*//username'; + input.simulate('change', { target: { value } }); + + expect(wrapper.find(EuiTableRow)).toHaveLength(1); + }); + it('shows default message when "accessAllEngines" is true', () => { const wrapper = shallow( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index b6110d692bc99..ae4d65fdf3a45 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -83,8 +83,10 @@ export const RoleMappingsTable: React.FC = ({ }); const filterResults = (result: SharedRoleMapping) => { + // Filter out non-alphanumeric characters, except for underscores, hyphens, and spaces + const sanitizedValue = filterValue.replace(/[^\w\s-]/g, ''); const values = Object.values(result); - const regexp = new RegExp(filterValue, 'i'); + const regexp = new RegExp(sanitizedValue, 'i'); return values.filter((x) => regexp.test(x)).length > 0; }; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 7199067a2c8f4..d6a891e3f6241 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -7,7 +7,11 @@ import { mockConfig, mockLogger } from '../__mocks__'; -import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants'; +import { + ENTERPRISE_SEARCH_KIBANA_COOKIE, + JSON_HEADER, + READ_ONLY_MODE_HEADER, +} from '../../common/constants'; import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler'; @@ -171,6 +175,28 @@ describe('EnterpriseSearchRequestHandler', () => { headers: mockExpectedResponseHeaders, }); }); + + it('filters out any _sessionData passed back from Enterprise Search', async () => { + const jsonWithSessionData = { + _sessionData: { + secrets: 'no peeking', + }, + regular: 'data', + }; + + EnterpriseSearchAPI.mockReturn(jsonWithSessionData, { headers: JSON_HEADER }); + + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/prep' }); + await makeAPICall(requestHandler); + + expect(responseMock.custom).toHaveBeenCalledWith({ + statusCode: 200, + body: { + regular: 'data', + }, + headers: mockExpectedResponseHeaders, + }); + }); }); }); @@ -378,6 +404,33 @@ describe('EnterpriseSearchRequestHandler', () => { }); }); + describe('setSessionData', () => { + it('sets the value of wsOAuthTokenPackage in a cookie', async () => { + const tokenPackage = 'some_encrypted_secrets'; + + const mockNow = 'Thu, 04 Mar 2021 22:40:32 GMT'; + const mockInAnHour = 'Thu, 04 Mar 2021 23:40:32 GMT'; + jest.spyOn(global.Date, 'now').mockImplementationOnce(() => { + return new Date(mockNow).valueOf(); + }); + + const sessionDataBody = { + _sessionData: { wsOAuthTokenPackage: tokenPackage }, + regular: 'data', + }; + + EnterpriseSearchAPI.mockReturn(sessionDataBody, { headers: JSON_HEADER }); + + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/' }); + await makeAPICall(requestHandler); + + expect(enterpriseSearchRequestHandler.headers).toEqual({ + ['set-cookie']: `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}; Path=/; Expires=${mockInAnHour}; SameSite=Lax; HttpOnly`, + ...mockExpectedResponseHeaders, + }); + }); + }); + it('isEmptyObj', async () => { expect(enterpriseSearchRequestHandler.isEmptyObj({})).toEqual(true); expect(enterpriseSearchRequestHandler.isEmptyObj({ empty: false })).toEqual(false); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index f47df58c4eca1..fb525740dd55b 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -16,7 +16,12 @@ import { Logger, } from 'src/core/server'; -import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants'; +import { + ENTERPRISE_SEARCH_KIBANA_COOKIE, + JSON_HEADER, + READ_ONLY_MODE_HEADER, +} from '../../common/constants'; + import { ConfigType } from '../index'; interface ConstructorDependencies { @@ -113,11 +118,17 @@ export class EnterpriseSearchRequestHandler { return this.handleInvalidDataError(response, url, json); } + // Intercept data that is meant for the server side session + const { _sessionData, ...responseJson } = json; + if (_sessionData) { + this.setSessionData(_sessionData); + } + // Pass successful responses back to the front-end return response.custom({ statusCode: status, headers: this.headers, - body: json, + body: _sessionData ? responseJson : json, }); } catch (e) { // Catch connection/auth errors @@ -270,6 +281,27 @@ export class EnterpriseSearchRequestHandler { this.headers[READ_ONLY_MODE_HEADER] = readOnlyMode as 'true' | 'false'; } + /** + * Extract Session Data + * + * In the future, this will set the keys passed back from Enterprise Search + * into the Kibana login session. + * For now we'll explicity look for the Workplace Search OAuth token package + * and stuff it into a cookie so it can be picked up later when we proxy the + * OAuth callback. + */ + setSessionData(sessionData: { [key: string]: string }) { + if (sessionData.wsOAuthTokenPackage) { + const anHourFromNow = new Date(Date.now()); + anHourFromNow.setHours(anHourFromNow.getHours() + 1); + + const cookiePayload = `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${sessionData.wsOAuthTokenPackage};`; + const cookieRestrictions = `Path=/; Expires=${anHourFromNow.toUTCString()}; SameSite=Lax; HttpOnly`; + + this.headers['set-cookie'] = `${cookiePayload} ${cookieRestrictions}`; + } + } + /** * Misc helpers */ diff --git a/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.test.ts b/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.test.ts new file mode 100644 index 0000000000000..9b2df3ce1373a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../common/constants'; + +import { getOAuthTokenPackageParams } from './get_oauth_token_package_params'; + +describe('getOAuthTokenPackage', () => { + const tokenPackage = 'some_encrypted_secrets'; + const tokenPackageCookie = `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}`; + const tokenPackageParams = { token_package: tokenPackage }; + + describe('when there are no cookie headers', () => { + it('returns an empty parameter set', () => { + expect(getOAuthTokenPackageParams(undefined)).toEqual({}); + }); + }); + + describe('when there is a single cookie header', () => { + it('returns an empty parameter set when our cookie is not there', () => { + const cookieHeader = '_st_fruit=banana'; + + expect(getOAuthTokenPackageParams(cookieHeader)).toEqual({}); + }); + + it('returns the token package when our cookie is the only one', () => { + const cookieHeader = `${tokenPackageCookie}`; + + expect(getOAuthTokenPackageParams(cookieHeader)).toEqual(tokenPackageParams); + }); + + it('returns the token package when there are other cookies in the header', () => { + const cookieHeader = `_chocolate=chip; ${tokenPackageCookie}; _oatmeal=raisin`; + + expect(getOAuthTokenPackageParams(cookieHeader)).toEqual(tokenPackageParams); + }); + }); + + describe('when there are multiple cookie headers', () => { + it('returns an empty parameter set when none of them include our cookie', () => { + const cookieHeaders = ['_st_fruit=banana', '_sid=12345']; + + expect(getOAuthTokenPackageParams(cookieHeaders)).toEqual({}); + }); + + it('returns the token package when our cookie is present', () => { + const cookieHeaders = ['_st_fruit=banana', `_heat=spicy; ${tokenPackageCookie}`]; + + expect(getOAuthTokenPackageParams(cookieHeaders)).toEqual(tokenPackageParams); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.ts b/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.ts new file mode 100644 index 0000000000000..d9db57717611d --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.ts @@ -0,0 +1,39 @@ +/* + * 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 { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../common/constants'; + +export const getOAuthTokenPackageParams = (rawCookieHeader: string | string[] | undefined) => { + // In the future the token package will be stored in the login session. For now it's in a cookie. + + if (!rawCookieHeader) { + return {}; + } + + /** + * A request can have multiple cookie headers and each header can hold multiple cookies. + * Within a header, cookies are separated by '; '. Here we are splitting out the individual + * cookies from the header(s) and looking for the specific one that holds our token package. + */ + + const cookieHeaders = Array.isArray(rawCookieHeader) ? rawCookieHeader : [rawCookieHeader]; + + let tokenPackage: string | undefined; + + cookieHeaders + .flatMap((rawHeader) => rawHeader.split('; ')) + .forEach((rawCookie) => { + const [cookieName, cookieValue] = rawCookie.split('='); + if (cookieName === ENTERPRISE_SEARCH_KIBANA_COOKIE) tokenPackage = cookieValue; + }); + + if (tokenPackage) { + return { token_package: tokenPackage }; + } else { + return {}; + } +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index a2fbe759f1a11..1443a78854abf 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -7,6 +7,8 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; +import { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../../common/constants'; + import { registerAccountSourcesRoute, registerAccountSourcesStatusRoute, @@ -1249,6 +1251,15 @@ describe('sources routes', () => { }); describe('GET /api/workplace_search/sources/create', () => { + const tokenPackage = 'some_encrypted_secrets'; + + const mockRequest = { + headers: { + authorization: 'BASIC 123', + cookie: `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}`, + }, + }; + let mockRouter: MockRouter; beforeEach(() => { @@ -1265,8 +1276,11 @@ describe('sources routes', () => { }); it('creates a request handler', () => { + mockRouter.callRoute(mockRequest as any); + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ path: '/ws/sources/create', + params: { token_package: tokenPackage }, }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index ab6eda64eace4..5c1ef8ed62982 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -7,6 +7,8 @@ import { schema } from '@kbn/config-schema'; +import { getOAuthTokenPackageParams } from '../../lib/get_oauth_token_package_params'; + import { RouteDependencies } from '../../plugin'; const schemaValuesSchema = schema.recordOf( @@ -862,9 +864,12 @@ export function registerOauthConnectorParamsRoute({ }), }, }, - enterpriseSearchRequestHandler.createRequest({ - path: '/ws/sources/create', - }) + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/sources/create', + params: getOAuthTokenPackageParams(request.headers.cookie), + })(context, request, response); + } ); } diff --git a/x-pack/plugins/file_upload/public/components/geojson_file_picker.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx similarity index 89% rename from x-pack/plugins/file_upload/public/components/geojson_file_picker.tsx rename to x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx index 724a23a43202f..2f31bc47b899c 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_file_picker.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx @@ -8,23 +8,22 @@ import React, { Component } from 'react'; import { EuiFilePicker, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { MB } from '../../common'; -import { getMaxBytesFormatted } from '../get_max_bytes'; -import { validateFile } from '../importer'; -import { GeoJsonImporter, GeoJsonPreview, GEOJSON_FILE_TYPES } from '../importer/geojson_importer'; +import { MB } from '../../../common'; +import { getMaxBytesFormatted } from '../../get_max_bytes'; +import { validateFile } from '../../importer'; +import { + GeoJsonImporter, + GeoJsonPreview, + GEOJSON_FILE_TYPES, +} from '../../importer/geojson_importer'; + +export type OnFileSelectParameters = GeoJsonPreview & { + indexName: string; + importer: GeoJsonImporter; +}; interface Props { - onSelect: ({ - features, - hasPoints, - hasShapes, - importer, - indexName, - previewCoverage, - }: GeoJsonPreview & { - indexName: string; - importer: GeoJsonImporter; - }) => void; + onSelect: (onFileSelectParameters: OnFileSelectParameters) => void; onClear: () => void; } diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx new file mode 100644 index 0000000000000..7ac0685e57700 --- /dev/null +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx @@ -0,0 +1,227 @@ +/* + * 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, { ChangeEvent, Component } from 'react'; +import { EuiForm, EuiFormRow, EuiFieldText, EuiSelect, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { GeoJsonFilePicker, OnFileSelectParameters } from './geojson_file_picker'; +import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; +import { + getExistingIndexNames, + getExistingIndexPatternNames, + checkIndexPatternValid, + // @ts-expect-error +} from '../../util/indexing_service'; + +const GEO_FIELD_TYPE_OPTIONS = [ + { + text: ES_FIELD_TYPES.GEO_POINT, + value: ES_FIELD_TYPES.GEO_POINT, + }, + { + text: ES_FIELD_TYPES.GEO_SHAPE, + value: ES_FIELD_TYPES.GEO_SHAPE, + }, +]; + +interface Props { + geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE; + indexName: string; + indexNameError?: string; + onFileClear: () => void; + onFileSelect: (onFileSelectParameters: OnFileSelectParameters) => void; + onGeoFieldTypeSelect: (geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE) => void; + onIndexNameChange: (name: string, error?: string) => void; +} + +interface State { + hasFile: boolean; + isPointsOnly: boolean; + indexNames: string[]; +} + +export class GeoJsonUploadForm extends Component { + private _isMounted = false; + + state: State = { + hasFile: false, + isPointsOnly: false, + indexNames: [], + }; + + async componentDidMount() { + this._isMounted = true; + this._loadIndexNames(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + _loadIndexNames = async () => { + const indexNameList = await getExistingIndexNames(); + const indexPatternList = await getExistingIndexPatternNames(); + if (this._isMounted) { + this.setState({ + indexNames: [...indexNameList, ...indexPatternList], + }); + } + }; + + _onFileSelect = (onFileSelectParameters: OnFileSelectParameters) => { + this.setState({ + hasFile: true, + isPointsOnly: onFileSelectParameters.hasPoints && !onFileSelectParameters.hasShapes, + }); + + this.props.onFileSelect(onFileSelectParameters); + + this._onIndexNameChange(onFileSelectParameters.indexName); + + const geoFieldType = + onFileSelectParameters.hasPoints && !onFileSelectParameters.hasShapes + ? ES_FIELD_TYPES.GEO_POINT + : ES_FIELD_TYPES.GEO_SHAPE; + this.props.onGeoFieldTypeSelect(geoFieldType); + }; + + _onFileClear = () => { + this.setState({ + hasFile: false, + isPointsOnly: false, + }); + + this.props.onFileClear(); + + this._onIndexNameChange(''); + }; + + _onGeoFieldTypeSelect = (event: ChangeEvent) => { + return this.props.onGeoFieldTypeSelect( + event.target.value as ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE + ); + }; + + _onIndexNameChange = (name: string) => { + let error: string | undefined; + if (this.state.indexNames.includes(name)) { + error = i18n.translate('xpack.fileUpload.indexSettings.indexNameAlreadyExistsErrorMessage', { + defaultMessage: 'Index name already exists.', + }); + } else if (!checkIndexPatternValid(name)) { + error = i18n.translate( + 'xpack.fileUpload.indexSettings.indexNameContainsIllegalCharactersErrorMessage', + { + defaultMessage: 'Index name contains illegal characters.', + } + ); + } + + this.props.onIndexNameChange(name, error); + }; + + _onIndexNameChangeEvent = (event: ChangeEvent) => { + this._onIndexNameChange(event.target.value); + }; + + _renderGeoFieldTypeSelect() { + return this.state.hasFile && this.state.isPointsOnly ? ( + + + + ) : null; + } + + _renderIndexNameInput() { + const isInvalid = this.props.indexNameError !== undefined; + return this.state.hasFile ? ( + <> + + + + + +
    +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.mustBeNewIndex', { + defaultMessage: 'Must be a new index', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.lowercaseOnly', { + defaultMessage: 'Lowercase only', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotInclude', { + defaultMessage: + 'Cannot include \\\\, /, *, ?, ", <, >, |, \ + " " (space character), , (comma), #', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotStartWith', { + defaultMessage: 'Cannot start with -, _, +', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotBe', { + defaultMessage: 'Cannot be . or ..', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.length', { + defaultMessage: + 'Cannot be longer than 255 bytes (note it is bytes, \ + so multi-byte characters will count towards the 255 \ + limit faster)', + })} +
  • +
+
+ + ) : null; + } + + render() { + return ( + + + {this._renderGeoFieldTypeSelect()} + {this._renderIndexNameInput()} + + ); + } +} diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index.ts b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index.ts new file mode 100644 index 0000000000000..6168835a3a145 --- /dev/null +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { GeoJsonUploadForm } from './geojson_upload_form'; +export { OnFileSelectParameters } from './geojson_file_picker'; diff --git a/x-pack/plugins/file_upload/public/components/import_complete_view.tsx b/x-pack/plugins/file_upload/public/components/import_complete_view.tsx index c6209ae765c72..29aed0cd52f7e 100644 --- a/x-pack/plugins/file_upload/public/components/import_complete_view.tsx +++ b/x-pack/plugins/file_upload/public/components/import_complete_view.tsx @@ -20,14 +20,14 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { CodeEditor, KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { getHttp, getUiSettings } from '../kibana_services'; -import { ImportResponse } from '../../common'; +import { ImportResults } from '../importer'; const services = { uiSettings: getUiSettings(), }; interface Props { - importResp?: ImportResponse; + importResults?: ImportResults; indexPatternResp?: object; } @@ -90,7 +90,7 @@ export class ImportCompleteView extends Component { } _getStatusMsg() { - if (!this.props.importResp || !this.props.importResp.success) { + if (!this.props.importResults || !this.props.importResults.success) { return i18n.translate('xpack.fileUpload.uploadFailureMsg', { defaultMessage: 'File upload failed.', }); @@ -99,15 +99,15 @@ export class ImportCompleteView extends Component { const successMsg = i18n.translate('xpack.fileUpload.uploadSuccessMsg', { defaultMessage: 'File upload complete: indexed {numFeatures} features.', values: { - numFeatures: this.props.importResp.docCount, + numFeatures: this.props.importResults.docCount, }, }); - const failedFeaturesMsg = this.props.importResp.failures.length + const failedFeaturesMsg = this.props.importResults.failures?.length ? i18n.translate('xpack.fileUpload.failedFeaturesMsg', { defaultMessage: 'Unable to index {numFailures} features.', values: { - numFailures: this.props.importResp.failures.length, + numFailures: this.props.importResults.failures.length, }, }) : ''; @@ -122,7 +122,7 @@ export class ImportCompleteView extends Component {

{this._getStatusMsg()}

{this._renderCodeEditor( - this.props.importResp, + this.props.importResults, i18n.translate('xpack.fileUpload.jsonImport.indexingResponse', { defaultMessage: 'Import response', }), diff --git a/x-pack/plugins/file_upload/public/components/index_settings.js b/x-pack/plugins/file_upload/public/components/index_settings.js deleted file mode 100644 index 7d7ab3516e651..0000000000000 --- a/x-pack/plugins/file_upload/public/components/index_settings.js +++ /dev/null @@ -1,201 +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, { Fragment, Component } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiFieldText, EuiSelect, EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - getExistingIndexNames, - getExistingIndexPatternNames, - checkIndexPatternValid, -} from '../util/indexing_service'; - -export class IndexSettings extends Component { - state = { - indexNameError: '', - indexDisabled: true, - indexName: '', - indexNameList: [], - indexPatternList: [], - }; - - async componentDidMount() { - this._isMounted = true; - this.loadExistingIndexData(); - } - - componentWillUnmount() { - this._isMounted = false; - } - - loadExistingIndexData = async () => { - const indexNameList = await getExistingIndexNames(); - const indexPatternList = await getExistingIndexPatternNames(); - if (this._isMounted) { - this.setState({ - indexNameList, - indexPatternList, - }); - } - }; - - componentDidUpdate(prevProps, prevState) { - const { indexNameError, indexName } = this.state; - if (prevState.indexNameError !== indexNameError) { - this.props.setHasIndexErrors(!!indexNameError); - } - const { disabled, indexTypes } = this.props; - const indexDisabled = disabled || !indexTypes || !indexTypes.length; - if (indexDisabled !== this.state.indexDisabled) { - this.setState({ indexDisabled }); - } - if (this.props.indexName !== indexName) { - this._setIndexName(this.props.indexName); - } - } - - _setIndexName = async (name) => { - const errorMessage = await this._isIndexNameAndPatternValid(name); - return this.setState({ - indexName: name, - indexNameError: errorMessage, - }); - }; - - _onIndexChange = async ({ target }) => { - const name = target.value; - await this._setIndexName(name); - this.props.setIndexName(name); - }; - - _isIndexNameAndPatternValid = async (name) => { - const { indexNameList, indexPatternList } = this.state; - const nameAlreadyInUse = [...indexNameList, ...indexPatternList].includes(name); - if (nameAlreadyInUse) { - return ( - - ); - } - - const indexPatternValid = checkIndexPatternValid(name); - if (!indexPatternValid) { - return ( - - ); - } - return ''; - }; - - render() { - const { setSelectedIndexType, indexTypes } = this.props; - const { indexNameError, indexDisabled, indexName } = this.state; - - return ( - - - } - > - ({ - text: indexType, - value: indexType, - }))} - value={this.props.selectedIndexType} - onChange={({ target }) => setSelectedIndexType(target.value)} - /> - - - } - isInvalid={indexNameError !== ''} - error={[indexNameError]} - > - - - {indexDisabled ? null : ( - - - -
    -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.mustBeNewIndex', { - defaultMessage: 'Must be a new index', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.lowercaseOnly', { - defaultMessage: 'Lowercase only', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotInclude', { - defaultMessage: - 'Cannot include \\\\, /, *, ?, ", <, >, |, \ - " " (space character), , (comma), #', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotStartWith', { - defaultMessage: 'Cannot start with -, _, +', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotBe', { - defaultMessage: 'Cannot be . or ..', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.length', { - defaultMessage: - 'Cannot be longer than 255 bytes (note it is bytes, \ - so multi-byte characters will count towards the 255 \ - limit faster)', - })} -
  • -
-
-
- )} -
- ); - } -} diff --git a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.js b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx similarity index 59% rename from x-pack/plugins/file_upload/public/components/json_upload_and_parse.js rename to x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx index ae9f5e9ac8154..fc00dba6ddf55 100644 --- a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.js +++ b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx @@ -7,41 +7,48 @@ import React, { Component, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiForm, EuiProgress, EuiText } from '@elastic/eui'; -import PropTypes from 'prop-types'; -import { IndexSettings } from './index_settings'; +import { EuiProgress, EuiText } from '@elastic/eui'; import { getIndexPatternService } from '../kibana_services'; -import { GeoJsonFilePicker } from './geojson_file_picker'; +import { GeoJsonUploadForm, OnFileSelectParameters } from './geojson_upload_form'; import { ImportCompleteView } from './import_complete_view'; import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/public'; +import { FileUploadComponentProps } from '../lazy_load_bundle'; +import { ImportResults } from '../importer'; +import { GeoJsonImporter } from '../importer/geojson_importer'; +import { IngestPipeline, Settings } from '../../common'; -const PHASE = { - CONFIGURE: 'CONFIGURE', - IMPORT: 'IMPORT', - COMPLETE: 'COMPLETE', -}; +enum PHASE { + CONFIGURE = 'CONFIGURE', + IMPORT = 'IMPORT', + COMPLETE = 'COMPLETE', +} -function getWritingToIndexMsg(progress) { +function getWritingToIndexMsg(progress: number) { return i18n.translate('xpack.fileUpload.jsonUploadAndParse.writingToIndex', { defaultMessage: 'Writing to index: {progress}% complete', values: { progress }, }); } -export class JsonUploadAndParse extends Component { - state = { - // Index state - indexTypes: [], - selectedIndexType: '', - indexName: '', - hasIndexErrors: false, - isIndexReady: false, +interface State { + geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE; + importStatus: string; + importResults?: ImportResults; + indexName: string; + indexNameError?: string; + indexPatternResp?: object; + phase: PHASE; +} - // Progress-tracking state +export class JsonUploadAndParse extends Component { + private _geojsonImporter?: GeoJsonImporter; + private _isMounted = false; + + state: State = { + geoFieldType: ES_FIELD_TYPES.GEO_SHAPE, importStatus: '', + indexName: '', phase: PHASE.CONFIGURE, - importResp: undefined, - indexPatternResp: undefined, }; componentDidMount() { @@ -52,45 +59,35 @@ export class JsonUploadAndParse extends Component { this._isMounted = false; if (this._geojsonImporter) { this._geojsonImporter.destroy(); - this._geojsonImporter = null; + this._geojsonImporter = undefined; } } componentDidUpdate() { - this._setIndexReady(); if (this.props.isIndexingTriggered && this.state.phase === PHASE.CONFIGURE) { this._import(); } } - _setIndexReady = () => { - const isIndexReady = - this._geojsonImporter !== undefined && - !!this.state.selectedIndexType && - !!this.state.indexName && - !this.state.hasIndexErrors && - this.state.phase === PHASE.CONFIGURE; - if (isIndexReady !== this.state.isIndexReady) { - this.setState({ isIndexReady }); - this.props.onIndexReady(isIndexReady); + _import = async () => { + if (!this._geojsonImporter) { + return; } - }; - _import = async () => { // // create index // - const settings = { + const settings = ({ number_of_shards: 1, - }; + } as unknown) as Settings; const mappings = { properties: { coordinates: { - type: this.state.selectedIndexType, + type: this.state.geoFieldType, }, }, }; - const ingestPipeline = {}; + const ingestPipeline = ({} as unknown) as IngestPipeline; this.setState({ importStatus: i18n.translate('xpack.fileUpload.jsonUploadAndParse.dataIndexingStarted', { defaultMessage: 'Creating index: {indexName}', @@ -98,7 +95,7 @@ export class JsonUploadAndParse extends Component { }), phase: PHASE.IMPORT, }); - this._geojsonImporter.setGeoFieldType(this.state.selectedIndexType); + this._geojsonImporter.setGeoFieldType(this.state.geoFieldType); const initializeImportResp = await this._geojsonImporter.initializeImport( this.state.indexName, settings, @@ -122,7 +119,7 @@ export class JsonUploadAndParse extends Component { this.setState({ importStatus: getWritingToIndexMsg(0), }); - const importResp = await this._geojsonImporter.import( + const importResults = await this._geojsonImporter.import( initializeImportResp.id, this.state.indexName, initializeImportResp.pipelineId, @@ -138,9 +135,9 @@ export class JsonUploadAndParse extends Component { return; } - if (!importResp.success) { + if (!importResults.success) { this.setState({ - importResp, + importResults, importStatus: i18n.translate('xpack.fileUpload.jsonUploadAndParse.dataIndexingError', { defaultMessage: 'Data indexing error', }), @@ -154,7 +151,7 @@ export class JsonUploadAndParse extends Component { // create index pattern // this.setState({ - importResp, + importResults, importStatus: i18n.translate('xpack.fileUpload.jsonUploadAndParse.creatingIndexPattern', { defaultMessage: 'Creating index pattern: {indexName}', values: { indexName: this.state.indexName }, @@ -197,35 +194,14 @@ export class JsonUploadAndParse extends Component { importStatus: '', }); this.props.onIndexingComplete({ - indexDataResp: importResp, + indexDataResp: importResults, indexPattern, }); }; - _onFileSelect = ({ features, hasPoints, hasShapes, importer, indexName, previewCoverage }) => { + _onFileSelect = ({ features, importer, indexName, previewCoverage }: OnFileSelectParameters) => { this._geojsonImporter = importer; - const geoFieldTypes = hasPoints - ? [ES_FIELD_TYPES.GEO_POINT, ES_FIELD_TYPES.GEO_SHAPE] - : [ES_FIELD_TYPES.GEO_SHAPE]; - - const newState = { - indexTypes: geoFieldTypes, - indexName, - }; - if (!this.state.selectedIndexType) { - // auto select index type - newState.selectedIndexType = - hasPoints && !hasShapes ? ES_FIELD_TYPES.GEO_POINT : ES_FIELD_TYPES.GEO_SHAPE; - } else if ( - this.state.selectedIndexType && - !geoFieldTypes.includes(this.state.selectedIndexType) - ) { - // unselected indexType if selected type is not longer an option - newState.selectedIndexType = ''; - } - this.setState(newState); - this.props.onFileUpload( { type: 'FeatureCollection', @@ -243,12 +219,20 @@ export class JsonUploadAndParse extends Component { } this.props.onFileRemove(); + }; + _onGeoFieldTypeSelect = (geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE) => { + this.setState({ geoFieldType }); + }; + + _onIndexNameChange = (name: string, error?: string) => { this.setState({ - indexTypes: [], - selectedIndexType: '', - indexName: '', + indexName: name, + indexNameError: error, }); + + const isReadyToImport = !!name && error === undefined; + this.props.onIndexReady(isReadyToImport); }; render() { @@ -266,38 +250,22 @@ export class JsonUploadAndParse extends Component { if (this.state.phase === PHASE.COMPLETE) { return ( ); } return ( - - - this.setState({ indexName })} - indexTypes={this.state.indexTypes} - selectedIndexType={this.state.selectedIndexType} - setSelectedIndexType={(selectedIndexType) => this.setState({ selectedIndexType })} - setHasIndexErrors={(hasIndexErrors) => this.setState({ hasIndexErrors })} - /> - + ); } } - -JsonUploadAndParse.defaultProps = { - isIndexingTriggered: false, -}; - -JsonUploadAndParse.propTypes = { - isIndexingTriggered: PropTypes.bool, - onIndexReadyStatusChange: PropTypes.func, - onIndexingComplete: PropTypes.func, - onIndexingError: PropTypes.func, - onFileUpload: PropTypes.func, - onFileRemove: PropTypes.func, -}; diff --git a/x-pack/plugins/file_upload/public/importer/geojson_importer/geojson_importer.ts b/x-pack/plugins/file_upload/public/importer/geojson_importer/geojson_importer.ts index f0ad083cab34e..d28b049e1214d 100644 --- a/x-pack/plugins/file_upload/public/importer/geojson_importer/geojson_importer.ts +++ b/x-pack/plugins/file_upload/public/importer/geojson_importer/geojson_importer.ts @@ -77,7 +77,7 @@ export class GeoJsonImporter extends Importer { public async import( id: string, index: string, - pipelineId: string, + pipelineId: string | undefined, setImportProgress: (progress: number) => void ): Promise { if (!id || !index) { @@ -163,7 +163,7 @@ export class GeoJsonImporter extends Importer { private async _importBlock( id: string, index: string, - pipelineId: string, + pipelineId: string | undefined, chunks: ImportDoc[][], blockSizeInBytes: number, setImportProgress: (progress: number) => void diff --git a/x-pack/plugins/file_upload/public/importer/importer.ts b/x-pack/plugins/file_upload/public/importer/importer.ts index a9844b1176baf..4a87d67d0616b 100644 --- a/x-pack/plugins/file_upload/public/importer/importer.ts +++ b/x-pack/plugins/file_upload/public/importer/importer.ts @@ -85,7 +85,7 @@ export abstract class Importer implements IImporter { public async import( id: string, index: string, - pipelineId: string, + pipelineId: string | undefined, setImportProgress: (progress: number) => void ): Promise { if (!id || !index) { diff --git a/x-pack/plugins/file_upload/public/importer/types.ts b/x-pack/plugins/file_upload/public/importer/types.ts index e99cdf380216d..7300b7cacfc7f 100644 --- a/x-pack/plugins/file_upload/public/importer/types.ts +++ b/x-pack/plugins/file_upload/public/importer/types.ts @@ -51,7 +51,7 @@ export interface IImporter { import( id: string, index: string, - pipelineId: string, + pipelineId: string | undefined, setImportProgress: (progress: number) => void ): Promise; } diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts index 36df353f65d8c..0a28e9e4dfc93 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts @@ -5,6 +5,5 @@ * 2.0. */ -// @ts-expect-error export { JsonUploadAndParse } from '../../components/json_upload_and_parse'; export { importerFactory } from '../../importer'; diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts index 5cfa77c86766d..387d69c5c0ca7 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts @@ -7,11 +7,12 @@ import uuid from 'uuid'; import Boom from '@hapi/boom'; -import { GetResponse } from 'elasticsearch'; +import { i18n } from '@kbn/i18n'; +import type { GetResponse } from 'elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; -import { ESSearchResponse as SearchResponse } from '../../../../../typings/elasticsearch'; +import type { ESSearchResponse as SearchResponse } from '../../../../../typings/elasticsearch'; import type { EnrollmentAPIKey, FleetServerEnrollmentAPIKey } from '../../types'; import { ENROLLMENT_API_KEYS_INDEX } from '../../constants'; import { agentPolicyService } from '../agent_policy'; @@ -19,6 +20,8 @@ import { escapeSearchQueryPhrase } from '../saved_object'; import { createAPIKey, invalidateAPIKeys } from './security'; +const uuidRegex = /^\([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\)$/; + export async function listEnrollmentApiKeys( esClient: ElasticsearchClient, options: { @@ -126,15 +129,55 @@ export async function generateEnrollmentAPIKey( name?: string; expiration?: string; agentPolicyId?: string; + forceRecreate?: boolean; } ): Promise { const id = uuid.v4(); - const { name: providedKeyName } = data; + const { name: providedKeyName, forceRecreate } = data; if (data.agentPolicyId) { await validateAgentPolicyId(soClient, data.agentPolicyId); } const agentPolicyId = data.agentPolicyId ?? (await agentPolicyService.getDefaultAgentPolicyId(soClient)); + + if (providedKeyName && !forceRecreate) { + let hasMore = true; + let page = 1; + let keys: EnrollmentAPIKey[] = []; + while (hasMore) { + const { items } = await listEnrollmentApiKeys(esClient, { + page: page++, + perPage: 100, + kuery: `policy_id:"${agentPolicyId}" AND name:${providedKeyName.replace(/ /g, '\\ ')}*`, + }); + if (items.length === 0) { + hasMore = false; + } else { + keys = keys.concat(items); + } + } + + if ( + keys.length > 0 && + keys.some((k: EnrollmentAPIKey) => + // Prevent false positives when the providedKeyName is a prefix of a token name that already exists + // After removing the providedKeyName and trimming whitespace, the only string left should be a uuid in parens. + k.name?.replace(providedKeyName, '').trim().match(uuidRegex) + ) + ) { + throw new Error( + i18n.translate('xpack.fleet.serverError.enrollmentKeyDuplicate', { + defaultMessage: + 'An enrollment key named {providedKeyName} already exists for agent policy {agentPolicyId}', + values: { + providedKeyName, + agentPolicyId, + }, + }) + ); + } + } + const name = providedKeyName ? `${providedKeyName} (${id})` : id; const key = await createAPIKey(soClient, name, { // Useless role to avoid to have the privilege of the user that created the key @@ -151,7 +194,11 @@ export async function generateEnrollmentAPIKey( }); if (!key) { - throw new Error('Unable to create an enrollment api key'); + throw new Error( + i18n.translate('xpack.fleet.serverError.unableToCreateEnrollmentKey', { + defaultMessage: 'Unable to create an enrollment api key', + }) + ); } const apiKey = Buffer.from(`${key.id}:${key.api_key}`).toString('base64'); @@ -187,7 +234,11 @@ export async function getEnrollmentAPIKeyById(esClient: ElasticsearchClient, api const [enrollmentAPIKey] = res.body.hits.hits.map(esDocToEnrollmentApiKey); if (enrollmentAPIKey?.api_key_id !== apiKeyId) { - throw new Error('find enrollmentKeyById returned an incorrect key'); + throw new Error( + i18n.translate('xpack.fleet.serverError.returnedIncorrectKey', { + defaultMessage: 'find enrollmentKeyById returned an incorrect key', + }) + ); } return enrollmentAPIKey; @@ -198,7 +249,12 @@ async function validateAgentPolicyId(soClient: SavedObjectsClientContract, agent await agentPolicyService.get(soClient, agentPolicyId); } catch (e) { if (e.isBoom && e.output.statusCode === 404) { - throw Boom.badRequest(`Agent policy ${agentPolicyId} does not exist`); + throw Boom.badRequest( + i18n.translate('xpack.fleet.serverError.agentPolicyDoesNotExist', { + defaultMessage: 'Agent policy {agentPolicyId} does not exist', + values: { agentPolicyId }, + }) + ); } throw e; } diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index d6bb04f5e572a..6f4ca6e231e9e 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -227,6 +227,7 @@ export async function setupFleet( return generateEnrollmentAPIKey(soClient, esClient, { name: `Default`, agentPolicyId: agentPolicy.id, + forceRecreate: true, // Always generate a new enrollment key when Fleet is being set up }); }) ); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts index 43f3643606015..5d4bbdde5620e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts @@ -16,6 +16,7 @@ import { FIELDS_BROWSER_SELECTED_CATEGORY_TITLE, FIELDS_BROWSER_SELECTED_CATEGORY_COUNT, FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT, + FIELDS_BROWSER_FILTER_INPUT, } from '../../screens/fields_browser'; import { TIMELINE_FIELDS_BUTTON } from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; @@ -194,7 +195,7 @@ describe('Fields Browser', () => { it('restores focus to the Customize Columns button when Esc is pressed', () => { openTimelineFieldsBrowser(); - cy.get('body').type('{esc}'); + cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{esc}'); cy.get(TIMELINE_FIELDS_BUTTON).should('have.focus'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts index c1fc46bc28683..25b790b57bc06 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts @@ -65,6 +65,17 @@ describe('timeline flyout button', () => { cy.get(CREATE_NEW_TIMELINE).should('not.be.visible'); }); + it('should render the global search dropdown when the input is focused', () => { + openTimelineUsingToggle(); + cy.get('[data-test-subj="nav-search-input"]').focus(); + cy.get('[data-test-subj="nav-search-input"]').should('be.focused'); + cy.get('[data-test-subj="nav-search-option"]').should('be.visible'); + cy.get('[data-test-subj="nav-search-option"]').first().trigger('mouseenter'); + // check that at least one item is visible in the search bar after mousing over, i.e. it's still usable. + cy.get('[data-test-subj="nav-search-option"]').its('length').should('be.gte', 1); + closeTimelineUsingCloseButton(); + }); + it('sets the data providers background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers area', () => { dragFirstHostToTimeline(); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index 1a59dd06570ce..be32c1c5dd036 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -219,8 +219,8 @@ export const getAlertAttachment = ({ alertId: string; index: string; loadingAlertData: boolean; - ruleId: string; - ruleName: string; + ruleId?: string | null; + ruleName?: string | null; }): EuiCommentProps => { return { username: ( diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index cf68d07859ced..bc7961b75a30a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -379,11 +379,10 @@ export const UserActionTree = React.memo( return comments; } - const ruleId = comment?.rule?.id ?? manualAlertsData[alertId]?.rule?.id?.[0] ?? ''; + const ruleId = + comment?.rule?.id ?? manualAlertsData[alertId]?.signal?.rule?.id?.[0] ?? null; const ruleName = - comment?.rule?.name ?? - manualAlertsData[alertId]?.rule?.name?.[0] ?? - i18n.UNKNOWN_RULE; + comment?.rule?.name ?? manualAlertsData[alertId]?.signal?.rule?.name?.[0] ?? null; return [ ...comments, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx index 228945bacf8a4..f4f610d07e2ff 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx @@ -46,11 +46,10 @@ describe('UserActionAvatar ', () => { expect(wrapper.text()).toBe('added an alert from Awesome rule'); }); - it('does NOT render the link when the alert is undefined', async () => { + it('does NOT render the link when the rule is null', async () => { const wrapper = mount( - {/* @ts-expect-error */} - + ); @@ -58,27 +57,7 @@ describe('UserActionAvatar ', () => { wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() ).toBeFalsy(); - expect(wrapper.text()).toBe('added an alert from '); - }); - - it('does NOT render the link when the rule is undefined', async () => { - const alert = { - alertId: 'alert-id-1', - commentType: CommentType.alert, - }; - - const wrapper = mount( - - {/* @ts-expect-error*/} - - - ); - - expect( - wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() - ).toBeFalsy(); - - expect(wrapper.text()).toBe('added an alert from '); + expect(wrapper.text()).toBe('added an alert from Unknown rule'); }); it('navigate to app on link click', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx index 2a604b7c54d6b..57c366d412660 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx @@ -6,6 +6,7 @@ */ import React, { memo, useCallback } from 'react'; +import { isEmpty } from 'lodash'; import { EuiText, EuiLoadingSpinner } from '@elastic/eui'; import { APP_ID } from '../../../../common/constants'; @@ -20,8 +21,8 @@ import { LinkAnchor } from '../../../common/components/links'; interface Props { alertId: string; commentType: CommentType; - ruleId: string; - ruleName: string; + ruleId?: string | null; + ruleName?: string | null; alertsCount?: number; loadingAlertData?: boolean; } @@ -51,16 +52,16 @@ const AlertCommentEventComponent: React.FC = ({ <> {`${i18n.ALERT_COMMENT_LABEL_TITLE} `} {loadingAlertData && } - {!loadingAlertData && ruleId !== '' && ( + {!loadingAlertData && !isEmpty(ruleId) && ( - {ruleName} + {ruleName ?? i18n.UNKNOWN_RULE} )} - {!loadingAlertData && ruleId === '' && {ruleName}} + {!loadingAlertData && isEmpty(ruleId) && i18n.UNKNOWN_RULE} ) : ( <> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 411e817c4407f..b8741197a7ab1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -219,7 +219,7 @@ export const RulesTables = React.memo( pageIndex: pagination.page - 1, pageSize: pagination.perPage, totalItemCount: pagination.total, - pageSizeOptions: [5, 10, 20, 50, 100, 200, 300, 400, 500], + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300, 400, 500, 600], }), [pagination] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index bd7c7fbd1941f..2602ca3f3cc7c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -6,8 +6,8 @@ */ import { i18n } from '@kbn/i18n'; -import { EuiFocusTrap } from '@elastic/eui'; -import React, { useEffect, useMemo } from 'react'; +import { EuiFocusTrap, EuiOutsideClickDetector } from '@elastic/eui'; +import React, { useEffect, useMemo, useCallback, useState, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; @@ -30,6 +30,8 @@ interface OwnProps { onAppLeave: (handler: AppLeaveHandler) => void; } +type VoidFunc = () => void; + const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { const dispatch = useDispatch(); const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); @@ -37,6 +39,45 @@ const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { getTimelineShowStatus(state, timelineId) ); + const [focusOwnership, setFocusOwnership] = useState(true); + const [triggerOnBlur, setTriggerOnBlur] = useState(true); + const callbackRef = useRef(null); + const searchRef = useRef(null); + + const handleSearch = useCallback(() => { + if (show && focusOwnership === false) { + setFocusOwnership(true); + } + }, [show, focusOwnership]); + const onOutsideClick = useCallback((event) => { + setFocusOwnership(false); + const classes = event.target.classList; + if (classes.contains('kbnSearchBar')) { + searchRef.current = event.target; + setTriggerOnBlur((prev) => !prev); + window.setTimeout(() => { + if (searchRef.current !== null) { + searchRef.current.focus(); + } + }, 0); + } + }, []); + + useEffect(() => { + if (searchRef.current != null) { + if (callbackRef.current !== null) { + searchRef.current.removeEventListener('blur', callbackRef.current); + } + searchRef.current.addEventListener('blur', handleSearch); + callbackRef.current = handleSearch; + } + return () => { + if (searchRef.current != null && callbackRef.current !== null) { + searchRef.current.removeEventListener('blur', callbackRef.current); + } + }; + }, [handleSearch, triggerOnBlur]); + useEffect(() => { onAppLeave((actions, nextAppId) => { if (show) { @@ -78,15 +119,18 @@ const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { } }); }, [dispatch, onAppLeave, show, timelineStatus, updated]); + return ( - <> - - - - - - - + + <> + + + + + + + + ); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.test.ts new file mode 100644 index 0000000000000..7e678b308e1f9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { + getSignalsMigrationSavedObjectMock, + getSignalsMigrationSavedObjectErrorMock, +} from './saved_objects_schema.mock'; +import { getMigrationSavedObjectsById } from './get_migration_saved_objects_by_id'; + +describe('getMigrationSavedObjectsById', () => { + let ids: string[]; + let soClient: ReturnType; + + beforeEach(() => { + ids = ['id1']; + soClient = savedObjectsClientMock.create(); + }); + + it('resolves an array of objects, if valid', async () => { + // @ts-expect-error stubbing our SO call + soClient.bulkGet.mockResolvedValue({ saved_objects: [getSignalsMigrationSavedObjectMock()] }); + const result = await getMigrationSavedObjectsById({ + ids, + soClient, + }); + + expect(result).toEqual([getSignalsMigrationSavedObjectMock()]); + }); + + it('rejects if SO client throws', () => { + const error = new Error('whoops'); + soClient.bulkGet.mockRejectedValue(error); + + return expect(getMigrationSavedObjectsById({ ids, soClient })).rejects.toThrow(error); + }); + + it('throws a 404 error if the response includes a 404', async () => { + soClient.bulkGet.mockResolvedValue({ + saved_objects: [ + // @ts-expect-error stubbing our SO call + getSignalsMigrationSavedObjectErrorMock({ statusCode: 404, message: 'not found' }), + ], + }); + + return expect(getMigrationSavedObjectsById({ ids, soClient })).rejects.toThrow( + expect.objectContaining({ statusCode: 404 }) + ); + }); + + it('rejects if response is invalid', () => { + // @ts-expect-error intentionally breaking the type + const badSavedObject = getSignalsMigrationSavedObjectMock({ destinationIndex: 4 }); + // @ts-expect-error stubbing our SO call + soClient.bulkGet.mockResolvedValue({ saved_objects: [badSavedObject] }); + + return expect(() => getMigrationSavedObjectsById({ ids, soClient })).rejects.toThrow( + 'Invalid value "4" supplied to "attributes,destinationIndex"' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts index c0010e4e357d3..7d66dca2ef192 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts @@ -5,10 +5,21 @@ * 2.0. */ +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + import { SavedObjectsClientContract } from 'src/core/server'; -import { findMigrationSavedObjects } from './find_migration_saved_objects'; -import { signalsMigrationType } from './saved_objects'; -import { SignalsMigrationSO } from './saved_objects_schema'; +import { validateEither } from '../../../../common/validate'; +import { signalsMigrationSOClient } from './saved_objects_client'; +import { SignalsMigrationSO, signalsMigrationSOs } from './saved_objects_schema'; + +class MigrationResponseError extends Error { + public readonly statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} /** * Retrieves a list of migrations SOs by their ID @@ -26,13 +37,22 @@ export const getMigrationSavedObjectsById = async ({ }: { ids: string[]; soClient: SavedObjectsClientContract; -}): Promise => - findMigrationSavedObjects({ - soClient, - options: { - search: ids.map((id) => `${signalsMigrationType}:${id}`).join(' OR '), - rootSearchFields: ['_id'], - sortField: 'updated', - sortOrder: 'desc', - }, - }); +}): Promise => { + const client = signalsMigrationSOClient(soClient); + const objects = ids.map((id) => ({ id })); + const { saved_objects: migrations } = await client.bulkGet(objects); + const error = migrations.find((migration) => migration.error)?.error; + + if (error) { + throw new MigrationResponseError(error.message, error.statusCode); + } + + return pipe( + migrations, + (ms) => validateEither(signalsMigrationSOs, ms), + fold( + (e) => Promise.reject(e), + (a) => Promise.resolve(a) + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts index a8bc20ec69302..ee15775c5cc24 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts @@ -9,6 +9,7 @@ import { SignalsMigrationSOClient } from './saved_objects_client'; const create = () => ({ + bulkGet: jest.fn(), create: jest.fn(), delete: jest.fn(), find: jest.fn(), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts index 2d2cbb8f80948..61f35af8cabc8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts @@ -11,11 +11,18 @@ import { SavedObjectsUpdateResponse, SavedObjectsFindOptions, SavedObjectsFindResponse, + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, + SavedObjectsBaseOptions, } from 'src/core/server'; import { signalsMigrationType } from './saved_objects'; import { SignalsMigrationSOAttributes } from './saved_objects_schema'; export interface SignalsMigrationSOClient { + bulkGet: ( + objects: Array>, + options?: SavedObjectsBaseOptions + ) => Promise>; find: ( options?: Omit ) => Promise>; @@ -32,6 +39,11 @@ export interface SignalsMigrationSOClient { export const signalsMigrationSOClient = ( savedObjectsClient: SavedObjectsClientContract ): SignalsMigrationSOClient => ({ + bulkGet: (objects, options) => + savedObjectsClient.bulkGet( + objects.map((o) => ({ ...o, type: signalsMigrationType })), + options + ), find: (options) => savedObjectsClient.find({ ...options, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts index b7a2a5a218de4..9c6eea8df84ac 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts @@ -27,3 +27,16 @@ export const getSignalsMigrationSavedObjectMock = ( ...overrides, }, }); + +export const getSignalsMigrationSavedObjectErrorMock = ( + overrides: Partial = {} +): SignalsMigrationSO => + ({ + id: 'dne-migration', + error: { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [security-solution-signals-migration/dne-migration] not found', + ...overrides, + }, + } as SignalsMigrationSO); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts index d1fe9d34165a8..3ce798ec5a5c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts @@ -28,6 +28,12 @@ const signalsMigrationSOGeneratedAttributes = { updatedBy: t.string, }; +const signalsMigrationSOError = { + statusCode: t.number, + error: t.string, + message: t.string, +}; + /** The attributes necessary to create a Signals Migration Saved Object */ @@ -59,11 +65,14 @@ export const signalsMigrationSOAttributes = t.exact( ); export type SignalsMigrationSOAttributes = t.TypeOf; -export const signalsMigrationSO = t.type({ - id: t.string, - attributes: signalsMigrationSOAttributes, - type: t.string, -}); +export const signalsMigrationSO = t.intersection([ + t.type({ + id: t.string, + attributes: signalsMigrationSOAttributes, + type: t.string, + }), + t.partial({ error: t.type(signalsMigrationSOError) }), +]); export type SignalsMigrationSO = t.TypeOf; export const signalsMigrationSOs = t.array(signalsMigrationSO); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 22ea4ebd079a8..cd34dbab83961 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8194,7 +8194,6 @@ "xpack.features.ossFeatures.visualizeShortUrlSubFeatureName": "短い URL", "xpack.features.savedObjectsManagementFeatureName": "保存されたオブジェクトの管理", "xpack.features.visualizeFeatureName": "Visualizeライブラリ", - "xpack.fileUpload.enterIndexName": "インデックス名を入力", "xpack.fileUpload.httpService.fetchError": "フェッチ実行エラー:{error}", "xpack.fileUpload.httpService.noUrl": "URLが指定されていません", "xpack.fileUpload.indexNameReqField": "インデックス名、必須フィールド", @@ -13047,7 +13046,6 @@ "xpack.ml.alertContext.anomalyExplorerUrlDescription": "異常エクスプローラーを開くURL", "xpack.ml.alertContext.isInterimDescription": "上位の一致に中間結果が含まれるかどうかを示します", "xpack.ml.alertContext.jobIdsDescription": "アラートインスタンスをトリガーしたジョブIDのリスト。", - "xpack.ml.alertContext.kibanaBasePathUrlDescription": "Kibanaベースパス", "xpack.ml.alertContext.scoreDescription": "異常スコア", "xpack.ml.alertContext.timestampDescription": "異常のタイムスタンプ", "xpack.ml.alertContext.timestampIso8601Description": "ISO8601形式の時刻", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 587bedb1183ab..d8e2f7c3882bf 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8264,7 +8264,6 @@ "xpack.features.ossFeatures.visualizeShortUrlSubFeatureName": "短 URL", "xpack.features.savedObjectsManagementFeatureName": "已保存对象管理", "xpack.features.visualizeFeatureName": "Visualize 库", - "xpack.fileUpload.enterIndexName": "输入索引名称", "xpack.fileUpload.httpService.fetchError": "执行提取时出错:{error}", "xpack.fileUpload.httpService.noUrl": "未提供 URL", "xpack.fileUpload.indexNameReqField": "索引名称,必填字段", @@ -13212,7 +13211,6 @@ "xpack.ml.alertContext.anomalyExplorerUrlDescription": "要在 Anomaly Explorer 中打开的 URL", "xpack.ml.alertContext.isInterimDescription": "表示排名靠前的命中是否包含中间结果", "xpack.ml.alertContext.jobIdsDescription": "触发告警实例的作业 ID 的列表", - "xpack.ml.alertContext.kibanaBasePathUrlDescription": "Kibana 基路径", "xpack.ml.alertContext.scoreDescription": "异常分数", "xpack.ml.alertContext.timestampDescription": "异常的时间戳", "xpack.ml.alertContext.timestampIso8601Description": "ISO8601 格式的时间", diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts index bba6ce1125c37..d54b4525459e8 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts @@ -102,6 +102,19 @@ export default ({ getService }: FtrProviderContext): void => { ); }); + it('returns a 404 trying to delete a migration that does not exist', async () => { + const { body } = await supertest + .delete(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: ['dne-migration'] }) + .expect(404); + + expect(body).to.eql({ + message: 'Saved object [security-solution-signals-migration/dne-migration] not found', + status_code: 404, + }); + }); + it('rejects the request if the user does not have sufficient privileges', async () => { await createUserAndRole(getService, ROLES.t1_analyst); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts index 0fd05904d5e33..0aac596cc3adb 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts @@ -221,14 +221,17 @@ export default ({ getService }: FtrProviderContext): void => { expect(indicesAfter).not.to.contain(createdMigration.index); }); - it('returns an empty array indicating a no-op for DNE migrations', async () => { + it('returns a 404 for DNE migrations', async () => { const { body } = await supertest .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) .set('kbn-xsrf', 'true') .send({ migration_ids: ['dne-migration'] }) - .expect(200); + .expect(404); - expect(body).to.eql({ migrations: [] }); + expect(body).to.eql({ + message: 'Saved object [security-solution-signals-migration/dne-migration] not found', + status_code: 404, + }); }); it('rejects the request if the user does not have sufficient privileges', async () => {