From cd857cb42e507e210f5b2ae2672bf251285e196f Mon Sep 17 00:00:00 2001 From: Tianle Huang <60111637+tianleh@users.noreply.github.com> Date: Wed, 10 Apr 2024 21:18:15 -0700 Subject: [PATCH 1/5] Improve dynamic configurations by adding cache and simplifying client fetch (#6364) * Improve dynamic config Signed-off-by: Tianle Huang * reset yml Signed-off-by: Tianle Huang * bring back previous changes Signed-off-by: Tianle Huang --------- Signed-off-by: Tianle Huang --- CHANGELOG.md | 1 + .../server/opensearch_config_client.test.ts | 106 ++++++++++++++--- .../server/opensearch_config_client.ts | 25 +++- .../application_config/server/plugin.test.ts | 108 +++++++++++++++++- .../application_config/server/plugin.ts | 22 +++- .../application_config/server/types.ts | 4 +- .../csp_handler/server/csp_handlers.test.ts | 7 ++ .../csp_handler/server/csp_handlers.ts | 6 +- 8 files changed, 249 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12391cc13614..423a7554f07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add APIs to support plugin state in request ([#6303](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6303)) - [Workspace] Filter left nav menu items according to the current workspace ([#6234](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6234)) - [Multiple Datasource] Refactor data source selector component to include placeholder and add tests ([#6372](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6372)) +- [Dynamic Configurations] Improve dynamic configurations by adding cache and simplifying client fetch ([#6364](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6364)) - [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400)) ### 🐛 Bug Fixes diff --git a/src/plugins/application_config/server/opensearch_config_client.test.ts b/src/plugins/application_config/server/opensearch_config_client.test.ts index 827d309303cb..17b22dad7295 100644 --- a/src/plugins/application_config/server/opensearch_config_client.test.ts +++ b/src/plugins/application_config/server/opensearch_config_client.test.ts @@ -48,7 +48,9 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = {}; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); const value = await client.getConfig(); @@ -77,7 +79,10 @@ describe('OpenSearch Configuration Client', () => { }), }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + + const cache = {}; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); await expect(client.getConfig()).rejects.toThrowError(ERROR_MESSAGE); }); @@ -99,11 +104,45 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = { + has: jest.fn().mockReturnValue(false), + set: jest.fn(), + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); const value = await client.getEntityConfig('config1'); expect(value).toBe('value1'); + expect(cache.set).toBeCalledWith('config1', 'value1'); + }); + + it('return configuration value from cache', async () => { + const opensearchClient = { + asInternalUser: { + get: jest.fn().mockImplementation(() => { + return { + body: { + _source: { + value: 'value1', + }, + }, + }; + }), + }, + }; + + const cache = { + has: jest.fn().mockReturnValue(true), + get: jest.fn().mockReturnValue('cachedValue'), + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); + + const value = await client.getEntityConfig('config1'); + + expect(value).toBe('cachedValue'); + expect(cache.get).toBeCalledWith('config1'); }); it('throws error when input is empty', async () => { @@ -121,7 +160,9 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = {}; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); await expect(client.getEntityConfig(EMPTY_INPUT)).rejects.toThrowError( ERROR_MESSSAGE_FOR_EMPTY_INPUT @@ -151,9 +192,16 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = { + has: jest.fn().mockReturnValue(false), + set: jest.fn(), + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); await expect(client.getEntityConfig('config1')).rejects.toThrowError(ERROR_MESSAGE); + + expect(cache.set).toBeCalledWith('config1', undefined); }); }); @@ -167,11 +215,16 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = { + del: jest.fn(), + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); const value = await client.deleteEntityConfig('config1'); expect(value).toBe('config1'); + expect(cache.del).toBeCalledWith('config1'); }); it('throws error when input entity is empty', async () => { @@ -183,7 +236,9 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = {}; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); await expect(client.deleteEntityConfig(EMPTY_INPUT)).rejects.toThrowError( ERROR_MESSSAGE_FOR_EMPTY_INPUT @@ -213,11 +268,16 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = { + del: jest.fn(), + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); const value = await client.deleteEntityConfig('config1'); expect(value).toBe('config1'); + expect(cache.del).toBeCalledWith('config1'); }); it('return deleted document entity when deletion fails due to document not found', async () => { @@ -241,11 +301,16 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = { + del: jest.fn(), + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); const value = await client.deleteEntityConfig('config1'); expect(value).toBe('config1'); + expect(cache.del).toBeCalledWith('config1'); }); it('throws error when opensearch throws error', async () => { @@ -271,7 +336,9 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = {}; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); await expect(client.deleteEntityConfig('config1')).rejects.toThrowError(ERROR_MESSAGE); }); @@ -287,11 +354,16 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = { + set: jest.fn(), + }; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); const value = await client.updateEntityConfig('config1', 'newValue1'); expect(value).toBe('newValue1'); + expect(cache.set).toBeCalledWith('config1', 'newValue1'); }); it('throws error when entity is empty ', async () => { @@ -303,7 +375,9 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = {}; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); await expect(client.updateEntityConfig(EMPTY_INPUT, 'newValue1')).rejects.toThrowError( ERROR_MESSSAGE_FOR_EMPTY_INPUT @@ -319,7 +393,9 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = {}; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); await expect(client.updateEntityConfig('config1', EMPTY_INPUT)).rejects.toThrowError( ERROR_MESSSAGE_FOR_EMPTY_INPUT @@ -349,7 +425,9 @@ describe('OpenSearch Configuration Client', () => { }, }; - const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger); + const cache = {}; + + const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger, cache); await expect(client.updateEntityConfig('config1', 'newValue1')).rejects.toThrowError( ERROR_MESSAGE diff --git a/src/plugins/application_config/server/opensearch_config_client.ts b/src/plugins/application_config/server/opensearch_config_client.ts index 9103919c396f..3a2c90147ade 100644 --- a/src/plugins/application_config/server/opensearch_config_client.ts +++ b/src/plugins/application_config/server/opensearch_config_client.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import LRUCache from 'lru-cache'; import { IScopedClusterClient, Logger } from '../../../../src/core/server'; - import { ConfigurationClient } from './types'; import { validate } from './string_utils'; @@ -12,32 +12,45 @@ export class OpenSearchConfigurationClient implements ConfigurationClient { private client: IScopedClusterClient; private configurationIndexName: string; private readonly logger: Logger; + private cache: LRUCache; constructor( scopedClusterClient: IScopedClusterClient, configurationIndexName: string, - logger: Logger + logger: Logger, + cache: LRUCache ) { this.client = scopedClusterClient; this.configurationIndexName = configurationIndexName; this.logger = logger; + this.cache = cache; } async getEntityConfig(entity: string) { const entityValidated = validate(entity, this.logger); + if (this.cache.has(entityValidated)) { + return this.cache.get(entityValidated); + } + + this.logger.info(`Key ${entityValidated} is not found from cache.`); + try { const data = await this.client.asInternalUser.get({ index: this.configurationIndexName, id: entityValidated, }); + const value = data?.body?._source?.value; - return data?.body?._source?.value || ''; + this.cache.set(entityValidated, value); + + return value; } catch (e) { const errorMessage = `Failed to get entity ${entityValidated} due to error ${e}`; this.logger.error(errorMessage); + this.cache.set(entityValidated, undefined); throw e; } } @@ -55,6 +68,8 @@ export class OpenSearchConfigurationClient implements ConfigurationClient { }, }); + this.cache.set(entityValidated, newValueValidated); + return newValueValidated; } catch (e) { const errorMessage = `Failed to update entity ${entityValidated} with newValue ${newValueValidated} due to error ${e}`; @@ -74,15 +89,19 @@ export class OpenSearchConfigurationClient implements ConfigurationClient { id: entityValidated, }); + this.cache.del(entityValidated); + return entityValidated; } catch (e) { if (e?.body?.error?.type === 'index_not_found_exception') { this.logger.info('Attemp to delete a not found index.'); + this.cache.del(entityValidated); return entityValidated; } if (e?.body?.result === 'not_found') { this.logger.info('Attemp to delete a not found document.'); + this.cache.del(entityValidated); return entityValidated; } diff --git a/src/plugins/application_config/server/plugin.test.ts b/src/plugins/application_config/server/plugin.test.ts index e1ac45444c14..5390223f4d87 100644 --- a/src/plugins/application_config/server/plugin.test.ts +++ b/src/plugins/application_config/server/plugin.test.ts @@ -6,6 +6,11 @@ import { of } from 'rxjs'; import { ApplicationConfigPlugin } from './plugin'; import { ConfigurationClient } from './types'; +import LRUCache from 'lru-cache'; +import { OpenSearchConfigurationClient } from './opensearch_config_client'; + +jest.mock('lru-cache'); +jest.mock('./opensearch_config_client'); describe('application config plugin', () => { it('throws error when trying to register twice', async () => { @@ -54,8 +59,8 @@ describe('application config plugin', () => { setup.registerConfigurationClient(client1); - const scopedClient = {}; - expect(setup.getConfigurationClient(scopedClient)).toBe(client1); + const request = {}; + expect(setup.getConfigurationClient(request)).toBe(client1); const client2: ConfigurationClient = { getConfig: jest.fn(), @@ -71,6 +76,103 @@ describe('application config plugin', () => { 'Configuration client is already registered! Cannot register again!' ); - expect(setup.getConfigurationClient(scopedClient)).toBe(client1); + expect(setup.getConfigurationClient(request)).toBe(client1); + }); + + it('getConfigurationClient returns opensearch client when no external registration', async () => { + let capturedLRUCacheConstructorArgs = []; + + const cache = { + get: jest.fn(), + }; + + LRUCache.mockImplementation(function (...args) { + capturedLRUCacheConstructorArgs = args; + return cache; + }); + + let capturedConfigurationClientConstructorArgs = []; + + const client: ConfigurationClient = { + getConfig: jest.fn(), + getEntityConfig: jest.fn(), + updateEntityConfig: jest.fn(), + deleteEntityConfig: jest.fn(), + }; + + OpenSearchConfigurationClient.mockImplementation(function (...args) { + capturedConfigurationClientConstructorArgs = args; + return client; + }); + + const logger = { + info: jest.fn(), + error: jest.fn(), + }; + + const initializerContext = { + logger: { + get: jest.fn().mockReturnValue(logger), + }, + config: { + legacy: { + globalConfig$: of({ + opensearchDashboards: { + configIndex: '.osd_test', + }, + }), + }, + }, + }; + + const plugin = new ApplicationConfigPlugin(initializerContext); + + const coreSetup = { + http: { + createRouter: jest.fn().mockImplementation(() => { + return { + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + }; + }), + }, + }; + + const setup = await plugin.setup(coreSetup); + + const scopedClient = { + asCurrentUser: jest.fn(), + }; + + const coreStart = { + opensearch: { + client: { + asScoped: jest.fn().mockReturnValue(scopedClient), + }, + }, + }; + + await plugin.start(coreStart); + + const request = {}; + + expect(setup.getConfigurationClient(request)).toBe(client); + + expect(capturedLRUCacheConstructorArgs).toEqual([ + { + max: 100, + maxAge: 600000, + }, + ]); + + expect(capturedConfigurationClientConstructorArgs).toEqual([ + scopedClient, + '.osd_test', + logger, + cache, + ]); + + expect(coreStart.opensearch.client.asScoped).toBeCalledTimes(1); }); }); diff --git a/src/plugins/application_config/server/plugin.ts b/src/plugins/application_config/server/plugin.ts index d0bd2ab42270..8536f7e134e3 100644 --- a/src/plugins/application_config/server/plugin.ts +++ b/src/plugins/application_config/server/plugin.ts @@ -6,14 +6,16 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; +import LRUCache from 'lru-cache'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger, - IScopedClusterClient, SharedGlobalConfig, + OpenSearchDashboardsRequest, + IClusterClient, } from '../../../core/server'; import { @@ -31,11 +33,20 @@ export class ApplicationConfigPlugin private configurationClient: ConfigurationClient; private configurationIndexName: string; + private clusterClient: IClusterClient; + + private cache: LRUCache; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.config$ = initializerContext.config.legacy.globalConfig$; this.configurationIndexName = ''; + this.clusterClient = null; + + this.cache = new LRUCache({ + max: 100, // at most 100 entries + maxAge: 10 * 60 * 1000, // 10 mins + }); } private registerConfigurationClient(configurationClient: ConfigurationClient) { @@ -50,15 +61,16 @@ export class ApplicationConfigPlugin this.configurationClient = configurationClient; } - private getConfigurationClient(scopedClusterClient: IScopedClusterClient): ConfigurationClient { + private getConfigurationClient(request?: OpenSearchDashboardsRequest): ConfigurationClient { if (this.configurationClient) { return this.configurationClient; } const openSearchConfigurationClient = new OpenSearchConfigurationClient( - scopedClusterClient, + this.clusterClient.asScoped(request), this.configurationIndexName, - this.logger + this.logger, + this.cache ); return openSearchConfigurationClient; @@ -81,6 +93,8 @@ export class ApplicationConfigPlugin } public start(core: CoreStart) { + this.clusterClient = core.opensearch.client; + return {}; } diff --git a/src/plugins/application_config/server/types.ts b/src/plugins/application_config/server/types.ts index 416d0258169e..c8039cf6cff3 100644 --- a/src/plugins/application_config/server/types.ts +++ b/src/plugins/application_config/server/types.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { IScopedClusterClient, Headers } from 'src/core/server'; +import { Headers, OpenSearchDashboardsRequest } from 'src/core/server'; export interface ApplicationConfigPluginSetup { - getConfigurationClient: (inputOpenSearchClient: IScopedClusterClient) => ConfigurationClient; + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient; registerConfigurationClient: (inputConfigurationClient: ConfigurationClient) => void; } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/src/plugins/csp_handler/server/csp_handlers.test.ts b/src/plugins/csp_handler/server/csp_handlers.test.ts index d6c2f8a16d49..b185410f6174 100644 --- a/src/plugins/csp_handler/server/csp_handlers.test.ts +++ b/src/plugins/csp_handler/server/csp_handlers.test.ts @@ -55,6 +55,7 @@ describe('CSP handlers', () => { }); expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + expect(getConfigurationClient).toBeCalledWith(request); }); it('do not add CSP headers when the client returns empty and CSP from YML already has frame-ancestors', async () => { @@ -89,6 +90,7 @@ describe('CSP handlers', () => { expect(toolkit.next).toHaveBeenCalledWith({}); expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + expect(getConfigurationClient).toBeCalledWith(request); }); it('add frame-ancestors CSP headers when the client returns empty and CSP from YML has no frame-ancestors', async () => { @@ -128,6 +130,7 @@ describe('CSP handlers', () => { }); expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + expect(getConfigurationClient).toBeCalledWith(request); }); it('do not add CSP headers when the configuration does not exist and CSP from YML already has frame-ancestors', async () => { @@ -164,6 +167,7 @@ describe('CSP handlers', () => { expect(toolkit.next).toBeCalledWith({}); expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + expect(getConfigurationClient).toBeCalledWith(request); }); it('add frame-ancestors CSP headers when the configuration does not exist and CSP from YML has no frame-ancestors', async () => { @@ -200,6 +204,7 @@ describe('CSP handlers', () => { }); expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + expect(getConfigurationClient).toBeCalledWith(request); }); it('do not add CSP headers when request dest exists and shall skip', async () => { @@ -235,6 +240,7 @@ describe('CSP handlers', () => { expect(toolkit.next).toBeCalledWith({}); expect(configurationClient.getEntityConfig).toBeCalledTimes(0); + expect(getConfigurationClient).toBeCalledTimes(0); }); it('do not add CSP headers when request dest does not exist', async () => { @@ -269,5 +275,6 @@ describe('CSP handlers', () => { expect(toolkit.next).toBeCalledWith({}); expect(configurationClient.getEntityConfig).toBeCalledTimes(0); + expect(getConfigurationClient).toBeCalledTimes(0); }); }); diff --git a/src/plugins/csp_handler/server/csp_handlers.ts b/src/plugins/csp_handler/server/csp_handlers.ts index 3bfa90115518..1a76ed942460 100644 --- a/src/plugins/csp_handler/server/csp_handlers.ts +++ b/src/plugins/csp_handler/server/csp_handlers.ts @@ -30,7 +30,7 @@ const CSP_RULES_CONFIG_KEY = 'csp.rules'; export function createCspRulesPreResponseHandler( core: CoreSetup, cspHeader: string, - getConfigurationClient: (scopedClusterClient: IScopedClusterClient) => ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, logger: Logger ): OnPreResponseHandler { return async ( @@ -47,9 +47,7 @@ export function createCspRulesPreResponseHandler( return toolkit.next({}); } - const [coreStart] = await core.getStartServices(); - - const client = getConfigurationClient(coreStart.opensearch.client.asScoped(request)); + const client = getConfigurationClient(request); const cspRules = await client.getEntityConfig(CSP_RULES_CONFIG_KEY, { headers: request.headers, From 46b17e4bb96e8b1293b23ac12a3ba739c2c83701 Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Wed, 10 Apr 2024 23:27:54 -0700 Subject: [PATCH 2/5] [OSCI] Removed KUI usage in visualizations -- Completion of Original PR (#6360) This PR completes https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5462 Please refer to the above PR for more details Signed-off-by: Anan Zhuang --- CHANGELOG.md | 1 + .../public/doc_links/doc_links_service.ts | 5 +++ .../embeddable/disabled_lab_visualization.tsx | 44 ++++++++++++------- src/plugins/visualizations/public/plugin.ts | 4 ++ src/plugins/visualizations/public/services.ts | 3 ++ 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 423a7554f07c..6e713d7caaf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Remove unused Sass in `tile_map` plugin ([#4110](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4110)) - [Multiple Datasource] Move data source selectable to its own folder, fix test and a few type errors for data source selectable component ([#6287](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6287)) +- Remove KUI usage in `disabled_lab_visualization` ([#5462](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5462)) ### 🔩 Tests diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index d73a663a64b3..41f52b7dc46a 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -424,6 +424,10 @@ export class DocLinksService { // https://opensearch.org/docs/latest/dashboards/visualize/viz-index/ guide: `${OPENSEARCH_WEBSITE_DOCS}visualize/viz-index/`, }, + management: { + // https://opensearch.org/docs/latest/dashboards/management/advanced-settings/ + advancedSettings: `${OPENSEARCH_DASHBOARDS_VERSIONED_DOCS}management/advanced-settings/`, + }, }, noDocumentation: { auditbeat: `${OPENSEARCH_WEBSITE_DOCS}tools/index/#downloads`, @@ -819,6 +823,7 @@ export interface DocLinksStart { readonly guide: string; }; readonly visualize: Record; + readonly management: Record; }; readonly noDocumentation: { readonly auditbeat: string; diff --git a/src/plugins/visualizations/public/embeddable/disabled_lab_visualization.tsx b/src/plugins/visualizations/public/embeddable/disabled_lab_visualization.tsx index 79d6369fa88d..7088fdd3b7ef 100644 --- a/src/plugins/visualizations/public/embeddable/disabled_lab_visualization.tsx +++ b/src/plugins/visualizations/public/embeddable/disabled_lab_visualization.tsx @@ -28,29 +28,39 @@ * under the License. */ +import React, { Fragment } from 'react'; import { FormattedMessage } from '@osd/i18n/react'; -import React from 'react'; +import { EuiEmptyPrompt, EuiLink, EuiText } from '@elastic/eui'; +import { getDocLinks } from '../services'; +import './_visualize_lab_disabled.scss'; export function DisabledLabVisualization({ title }: { title: string }) { + const docLinks = getDocLinks(); + const advancedSettingsLink = docLinks.links.opensearchDashboards.management.advancedSettings; return (
- ); } diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 3542e0cc26ff..bd255b82c6af 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -63,6 +63,7 @@ import { setSavedSearchLoader, setEmbeddable, setNotifications, + setDocLinks, } from './services'; import { VISUALIZE_EMBEDDABLE_TYPE, @@ -96,6 +97,7 @@ import { import { createSavedSearchesLoader } from '../../discover/public'; import { DashboardStart } from '../../dashboard/public'; import { createSavedAugmentVisLoader } from '../../vis_augmenter/public'; +import { DocLinksStart } from '../../../core/public'; /** * Interface for this plugin's returned setup/start contracts. @@ -133,6 +135,7 @@ export interface VisualizationsStartDeps { getAttributeService: DashboardStart['getAttributeService']; savedObjectsClient: SavedObjectsClientContract; notifications: NotificationsStart; + docLinks: DocLinksStart; } /** @@ -224,6 +227,7 @@ export class VisualizationsPlugin }); setSavedSearchLoader(savedSearchLoader); setNotifications(core.notifications); + setDocLinks(core.docLinks); return { ...types, showNewVisModal, diff --git a/src/plugins/visualizations/public/services.ts b/src/plugins/visualizations/public/services.ts index a99a7010af28..62266ff2ba53 100644 --- a/src/plugins/visualizations/public/services.ts +++ b/src/plugins/visualizations/public/services.ts @@ -54,6 +54,7 @@ import { SavedVisualizationsLoader } from './saved_visualizations'; import { SavedObjectLoader } from '../../saved_objects/public'; import { EmbeddableStart } from '../../embeddable/public'; import { SavedObjectLoaderAugmentVis } from '../../vis_augmenter/public'; +import { DocLinksStart } from '../../../core/public'; export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); @@ -116,3 +117,5 @@ export const [getNotifications, setNotifications] = createGetterSetter('savedAugmentVisLoader'); + +export const [getDocLinks, setDocLinks] = createGetterSetter('docLinks'); From 79ddfb98f3dd490e5a5cfcb445657658050b7a3c Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Thu, 11 Apr 2024 14:18:40 -0700 Subject: [PATCH 3/5] Fix tests in 6360 (#6411) Signed-off-by: Anan Zhuang --- .../dashboard_listing.test.tsx.snap | 15 +++++++++++++++ .../dashboard_top_nav.test.tsx.snap | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index 5e6645f56d9c..db34c4f229bb 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -813,6 +813,9 @@ exports[`dashboard listing hideWriteControls 1`] = ` "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -1955,6 +1958,9 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -3158,6 +3164,9 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -4361,6 +4370,9 @@ exports[`dashboard listing renders table rows 1`] = ` "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -5564,6 +5576,9 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index e7e215dfb6bb..51679fa90b36 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -705,6 +705,9 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -1673,6 +1676,9 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -2641,6 +2647,9 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -3609,6 +3618,9 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -4577,6 +4589,9 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", @@ -5545,6 +5560,9 @@ exports[`Dashboard top nav render with all components 1`] = ` "tls": "https://opensearch.org/docs/mocked-test-branch/dashboards/install/tls/", }, "introduction": "https://opensearch.org/docs/mocked-test-branch/dashboards/index/", + "management": Object { + "advancedSettings": "https://opensearch.org/docs/mocked-test-branch/dashboards/management/advanced-settings/", + }, "mapTiles": "https://opensearch.org/docs/mocked-test-branch/dashboards/maptiles", "notebooks": Object { "base": "https://opensearch.org/docs/mocked-test-branch/dashboards/notebooks", From 28b60ef5da5391b8ee13caafb6914dce893c9a3c Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Thu, 11 Apr 2024 16:24:55 -0700 Subject: [PATCH 4/5] Fix Lint Checker error due to unvisiable URL (#6413) Issue Resolved https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6412 Signed-off-by: Anan Zhuang --- .lycheeexclude | 1 + 1 file changed, 1 insertion(+) diff --git a/.lycheeexclude b/.lycheeexclude index 636c832d1709..e8e86359b462 100644 --- a/.lycheeexclude +++ b/.lycheeexclude @@ -18,6 +18,7 @@ https://tiles.maps.search-services.aws.a2z.com/ https://telemetry.opensearch.org/ https://telemetry-staging.opensearch.org/ https://api.github.com/repos/opensearch-project/OpenSearch-Dashboards/ +https://www.quandl.com/api/v1/datasets/ file:///* git://* From 7eda01a6741411e81001574dcc90fb5d2e35b3bf Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Fri, 12 Apr 2024 11:26:05 +0800 Subject: [PATCH 5/5] [Workspace] Add duplicate saved objects API (#6288) * Add copy saved objects API Signed-off-by: gaobinlong * Modify change log Signed-off-by: gaobinlong * Add documents for all saved objects APIs Signed-off-by: gaobinlong * Revert the yml file change Signed-off-by: gaobinlong * Move the duplicate api to workspace plugin Signed-off-by: gaobinlong * Modify change log Signed-off-by: gaobinlong * Modify api doc Signed-off-by: gaobinlong * Check target workspace exists or not Signed-off-by: gaobinlong * Remove unused import Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong * Modify change log Signed-off-by: gaobinlong * Modify workspace doc Signed-off-by: gaobinlong * Add more unit tests Signed-off-by: gaobinlong * Some minor change Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong * Modify test description Signed-off-by: gaobinlong * Optimize test description Signed-off-by: gaobinlong * Modify test case Signed-off-by: gaobinlong * Minor change Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong --- CHANGELOG.md | 1 + src/plugins/saved_objects/README.md | 594 ++++++++++++++++++ src/plugins/workspace/README.md | 319 ++++++++++ .../integration_tests/duplicate.test.ts | 326 ++++++++++ .../server/integration_tests/routes.test.ts | 146 +++++ src/plugins/workspace/server/plugin.ts | 2 + .../workspace/server/routes/duplicate.ts | 98 +++ src/plugins/workspace/server/routes/index.ts | 8 +- 8 files changed, 1493 insertions(+), 1 deletion(-) create mode 100644 src/plugins/workspace/README.md create mode 100644 src/plugins/workspace/server/integration_tests/duplicate.test.ts create mode 100644 src/plugins/workspace/server/routes/duplicate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e713d7caaf1..10ec01eab952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add update workspace page ([#6270](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6270)) - [Multiple Datasource] Make sure customer always have a default datasource ([#6237](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6237)) - [Workspace] Add workspace list page ([#6182](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6182)) +- [Workspace] Add API to duplicate saved objects among workspaces ([#6288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6288)) - [Workspace] Add workspaces column to saved objects page ([#6225](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6225)) - [Multiple Datasource] Enhanced data source selector with default datasource shows as first choice ([#6293](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6293)) - [Multiple Datasource] Add multi data source support to sample vega visualizations ([#6218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6218)) diff --git a/src/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index 2f7d98dbb36b..f323b4a94609 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -175,3 +175,597 @@ The migraton version will be saved as a `migrationVersion` attribute in the save ``` For a more detailed explanation on the migration, refer to [`saved objects management`](src/core/server/saved_objects/migrations/README.md). + +## Server APIs + +### Get saved objects API + +Retrieve a single saved object by its ID. + +* Path and HTTP methods + +```json +GET :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | YES | The ID of the saved object. | + +* Example request + +```json +GET api/saved_objects/index-pattern/619cc200-ecd0-11ee-95b1-e7363f9e289d +``` + +* Example response + +```json +{ + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "type": "index-pattern", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + } +} +``` + +### Bulk get saved objects API + +Retrieve mutiple saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_bulk_get +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES | The ID of the saved object. | +| `fields` | Array | NO | The fields of the saved obejct need to be returned in the response. | + +* Example request + +```json +POST api/saved_objects/_bulk_get +[ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d" + }, + { + "type": "config", + "id": "3.0.0" + } +] +``` + +* Example response + +```json +{ + "saved_objects": [ + { + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "type": "index-pattern", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + } + }, + { + "id": "3.0.0", + "type": "config", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-19T06:11:41.608Z", + "version": "WzAsMV0=", + "attributes": { + "buildNum": 9007199254740991 + }, + "references": [ + + ], + "migrationVersion": { + "config": "7.9.0" + } + } + ] +} +``` + +### Find saved objects API + +Retrieve a paginated set of saved objects by mulitple conditions. + +* Path and HTTP methods + +```json +GET :/api/saved_objects/_find +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `per_page` | Number | NO | The number of saved objects to return in each page. | +| `page` | Number | NO | The page of saved objects to return. | +| `search` | String | NO | A `simple_query_string` query DSL that used to filter the saved objects. | +| `default_search_operator` | String | NO | The default operator to use for the `simple_query_string` query. | +| `search_fields` | Array | NO | The fields to perform the `simple_query_string` parsed query against. | +| `fields` | Array | NO | The fields of the saved obejct need to be returned in the response. | +| `sort_field` | String | NO | The field used for sorting the response. | +| `has_reference` | Object | NO | Filters to objects that have a relationship with the type and ID combination. | +| `filter` | String | NO | The query string used to filter the attribute of the saved object. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +GET api/saved_objects/_find?type=index-pattern&search_fields=title +``` + +* Example response + +```json +{ + "page": 1, + "per_page": 20, + "total": 2, + "saved_objects": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "namespaces": [ + "default" + ], + "score": 0 + }, + { + "type": "index-pattern", + "id": "2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-28T07:10:13.513Z", + "version": "WzEwLDJd", + "workspaces": [ + "9gt4lB" + ], + "namespaces": [ + "default" + ], + "score": 0 + } + ] +} +``` + +### Create saved objects API + +Create saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO |The ID of the saved object. | + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/index-pattern/test-pattern +{ + "attributes": { + "title": "test-pattern-*" + } +} +``` + +* Example response + +```json +{ + "type": "index-pattern", + "id": "test-pattern", + "attributes": { + "title": "test-pattern-*" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-29T05:55:09.270Z", + "version": "WzExLDJd", + "namespaces": [ + "default" + ] +} +``` + +### Bulk create saved objects API + +Bulk create saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_bulk_create +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | NO |The ID of the saved object. | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | +| `version` | String | NO | The version of the saved object. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/_bulk_create +[ + { + "type": "index-pattern", + "id": "test-pattern1", + "attributes": { + "title": "test-pattern1-*" + } + } +] +``` + +* Example response + +```json +{ + "saved_objects": [ + { + "type": "index-pattern", + "id": "test-pattern1", + "attributes": { + "title": "test-pattern1-*" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-29T06:01:59.453Z", + "version": "WzEyLDJd", + "namespaces": [ + "default" + ] + } + ] +} +``` +### Upate saved objects API + +Update saved objects. + +* Path and HTTP methods + +```json +PUT :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO |The ID of the saved object. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | + +* Example request + +```json +PUT api/saved_objects/index-pattern/test-pattern +{ + "attributes": { + "title": "test-pattern-update-*" + } +} +``` + +* Example response + +```json +{ + "id": "test-pattern", + "type": "index-pattern", + "updated_at": "2024-03-29T06:04:32.743Z", + "version": "WzEzLDJd", + "namespaces": [ + "default" + ], + "attributes": { + "title": "test-pattern-update-*" + } +} +``` +### Delete saved objects API + +Delete saved objects. + +* Path and HTTP methods + +```json +DELETE :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO | The ID of the saved object. | + +* Example request + +```json +DELETE api/saved_objects/index-pattern/test-pattern +``` + +* Example response + +```json +{} +``` +### Export saved object API + +Export saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_export +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String|Array | NO | The types of the saved object to be included in the export. | +| `objects` | Array | NO | A list of saved objects to export. | +| `includeReferencesDeep` | Boolean | NO | Includes all of the referenced objects in the export. | +| `excludeExportDetails` | Boolean | NO | Exclude the export summary in the export. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/_export +{ + "type": "index-pattern" +} +``` + +* Example response + +```json +{"attributes":{"fields":"[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]","title":"test*"},"id":"2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-28T07:10:13.513Z","version":"WzEwLDJd","workspaces":["9gt4lB"]} +{"attributes":{"fields":"[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]","title":"test*"},"id":"619cc200-ecd0-11ee-95b1-e7363f9e289d","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-28T06:57:03.008Z","version":"WzksMl0="} +{"attributes":{"title":"test-pattern1-*"},"id":"test-pattern1","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-29T06:01:59.453Z","version":"WzEyLDJd"} +{"exportedCount":3,"missingRefCount":0,"missingReferences":[]} +``` + +### Import saved object API + +Import saved objects from the file generated by the export API. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_import +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `createNewCopies` | Boolean | NO | Creates copies of the saved objects, genereate new IDs for the imported saved obejcts and resets the reference. | +| `overwrite` | Boolean | NO | Overwrites the saved objects when they already exist. | +| `dataSourceId` | String | NO | The ID of the data source. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Request body + +The request body must include a multipart/form-data. + +* Example request + +```json +POST api/saved_objects/_import?createNewCopies=true --form file=@export.ndjson +``` + +* Example response + +```json +{ + "successCount": 3, + "success": true, + "successResults": [ + { + "type": "index-pattern", + "id": "2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "f0b08067-d6ab-4153-ba7d-0304506430d6" + }, + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "ffd3719c-2314-4022-befc-7d3007225952" + }, + { + "type": "index-pattern", + "id": "test-pattern1", + "meta": { + "title": "test-pattern1-*", + "icon": "indexPatternApp" + }, + "destinationId": "e87e7f2d-8498-4e44-8d25-f7d41f3b3844" + } + ] +} +``` + +### Resolve import saved objects errors API + +Resolve the errors if the import API returns errors, this API can be used to retry importing some saved obejcts, overwrite specific saved objects, or change the references to different saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_resolve_import_errors +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `createNewCopies` | Boolean | NO | Creates copies of the saved objects, genereate new IDs for the imported saved obejcts and resets the reference. | +| `dataSourceId` | String | NO | The ID of the data source. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Request body + +The request body must include a multipart/form-data. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `file` | ndjson file | YES | The same file given to the import API. | +| `retries` | Array | YES | The retry operations. | + +The attrbutes of the object in the `objects` parameter are as follows: +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES |The ID of the saved object. | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | +| `destinationId` | String | NO | The destination ID that the imported object should have, if different from the current ID. | +| `replaceReferences` | Array | NO | A list of `type`, `from`, and `to` to be used to change the saved object's references. | +| `ignoreMissingReferences` | Boolean | NO | If `true`, ignores missing reference errors, defaults to `false`. | + +* Example request + +```json +POST api/saved_objects/_import?createNewCopies=true --form file=@export.ndjson --form retries='[{"type":"index-pattern","id":"my-pattern","overwrite":true}]' + +``` + +* Example response + +```json +{ + "successCount": 0, + "success": true +} +``` diff --git a/src/plugins/workspace/README.md b/src/plugins/workspace/README.md new file mode 100644 index 000000000000..7e3fff562d82 --- /dev/null +++ b/src/plugins/workspace/README.md @@ -0,0 +1,319 @@ +# Workspace + +## Server APIs + +### List workspaces API + +List workspaces. + +* Path and HTTP methods + +```json +POST :/api/workspaces/_list +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `search` | String | NO | A `simple_query_string` query DSL used to search the workspaces. | +| `searchFields` | Array | NO | The fields to perform the `simple_query_string` parsed query against. | +| `sortField` | String | NO | The fields used for sorting the response. | +| `sortOrder` | String | NO | The order used for sorting the response. | +| `perPage` | String | NO | The number of workspaces to return in each page. | +| `page` | String | NO | The page of workspaces to return. | +| `permissionModes` | Array | NO | The permission mode list. | + +* Example request + +```json +POST api/workspaces/_list +``` + +* Example response + +```json +{ + "success": true, + "result": { + "page": 1, + "per_page": 20, + "total": 3, + "workspaces": [ + { + "name": "test1", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query", + "dev_tools" + ], + "id": "hWNZls" + }, + { + "name": "test2", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query" + ], + "id": "SnkOPt" + }, + { + "name": "Global workspace", + "features": [ + "*", + "!@management" + ], + "reserved": true, + "id": "public" + } + ] + } +} +``` + + +### Get workspace API + +Retrieve a single workspace. + +* Path and HTTP methods + +```json +GET :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + +* Example request + +```json +GET api/workspaces/SnkOPt +``` + +* Example response + +```json +{ + "success": true, + "result": { + "name": "test2", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query" + ], + "id": "SnkOPt" + } +} +``` + +### Create workspace API + +Create a workspace. + +* Path and HTTP methods + +```json +POST :/api/workspaces +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the workspace. | +| `permissions` | Object | NO | The permission info of the workspace. | + + +* Example request + +```json +POST api/workspaces +{ + "attributes": { + "name": "test4", + "description": "test4" + } +} +``` + +* Example response + +```json +{ + "success": true, + "result": { + "id": "eHVoCJ" + } +} +``` + +### Update workspace API + +Update the attributes and permissions of a workspace. + +* Path and HTTP methods + +```json +PUT :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the workspace. | +| `permissions` | Object | NO | The permission info of the workspace. | + + +* Example request + +```json +PUT api/workspaces/eHVoCJ +{ + "attributes": { + "name": "test4", + "description": "test update" + } +} +``` + +* Example response + +```json +{ + "success": true, + "result": true +} +``` + +### Delete workspace API + +Delete a workspace. + +* Path and HTTP methods + +```json +DELETE :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + + +* Example request + +```json +DELETE api/workspaces/eHVoCJ +``` + +* Example response + +```json +{ + "success": true, + "result": true +} +``` + +### Duplicate saved objects API + +Duplicate saved objects among workspaces. + +* Path and HTTP methods + +```json +POST :/api/workspaces/_duplicate_saved_objects +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objects` | Array | YES | A list of saved objects to copy. | +| `targetWorkspace` | String | YES | The ID of the workspace to copy to. | +| `includeReferencesDeep` | Boolean | NO | Copy all of the referenced objects of the specified objects to the target workspace . Defaults to `true`.| + +The attrbutes of the object in the `objects` parameter are as follows: +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES | The ID of the saved object. | + +* Example request + +```json +POST api/workspaces/_duplicate_saved_objects +{ + "objects": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d" + } + ], + "targetWorkspace": "9gt4lB" +} +``` + +* Example response + +```json +{ + "successCount": 1, + "success": true, + "successResults": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "f4b724fd-9647-4bbf-bf59-610b43a62c75" + } + ] +} +``` + diff --git a/src/plugins/workspace/server/integration_tests/duplicate.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts new file mode 100644 index 000000000000..e994586c631c --- /dev/null +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -0,0 +1,326 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as exportMock from '../../../../core/server'; +import supertest from 'supertest'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; +import { UnwrapPromise } from '@osd/utility-types'; +import { loggingSystemMock, savedObjectsClientMock } from '../../../../core/server/mocks'; +import { setupServer } from '../../../../core/server/test_utils'; +import { registerDuplicateRoute } from '../routes/duplicate'; +import { createListStream } from '../../../../core/server/utils/streams'; +import Boom from '@hapi/boom'; + +jest.mock('../../../../core/server/saved_objects/export', () => ({ + exportSavedObjectsToStream: jest.fn(), +})); + +type SetupServerReturn = UnwrapPromise>; + +const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; +const URL = '/api/workspaces/_duplicate_saved_objects'; +const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; +const logger = loggingSystemMock.create(); +const clientMock = { + init: jest.fn(), + enterWorkspace: jest.fn(), + getCurrentWorkspaceId: jest.fn(), + getCurrentWorkspace: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + stop: jest.fn(), + setup: jest.fn(), + destroy: jest.fn(), + setSavedObjects: jest.fn(), +}; + +export const createExportableType = (name: string): exportMock.SavedObjectsType => { + return { + name, + hidden: false, + namespaceType: 'single', + mappings: { + properties: {}, + }, + management: { + importableAndExportable: true, + }, + }; +}; + +describe(`duplicate saved objects among workspaces`, () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + const emptyResponse = { saved_objects: [], total: 0, per_page: 0, page: 0 }; + const mockIndexPattern = { + type: 'index-pattern', + id: 'my-pattern', + attributes: { title: 'my-pattern-*' }, + references: [], + }; + const mockVisualization = { + type: 'visualization', + id: 'my-visualization', + attributes: { title: 'Test visualization' }, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'my-pattern', + }, + ], + }; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( + allowedTypes.map(createExportableType) + ); + handlerContext.savedObjects.typeRegistry.getType.mockImplementation( + (type: string) => + // other attributes aren't needed for the purposes of injecting metadata + ({ management: { icon: `${type}-icon` } } as any) + ); + + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.find.mockResolvedValue(emptyResponse); + savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); + + const router = httpSetup.createRouter(''); + + registerDuplicateRoute(router, logger.get(), clientMock, 10000); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('duplicate failed if the requested saved objects are not valid', async () => { + const savedObjects = [mockIndexPattern, mockDashboard]; + clientMock.get.mockResolvedValueOnce({ success: true }); + exportSavedObjectsToStream.mockImplementation(() => { + const err = Boom.badRequest(); + err.output.payload.attributes = { + objects: savedObjects, + }; + throw err; + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.error).toEqual('Bad Request'); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + it('requires objects', async () => { + const result = await supertest(httpSetup.server.listener).post(URL).send({}).expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.objects]: expected value of type [array] but got [undefined]"` + ); + }); + + it('requires target workspace', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.targetWorkspace]: expected value of type [string] but got [undefined]"` + ); + }); + + it('target workspace does not exist', async () => { + clientMock.get.mockResolvedValueOnce({ success: false }); + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'non-existen-workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Get target workspace non-existen-workspace error: undefined"` + ); + }); + + it('duplicate unsupported objects', async () => { + clientMock.get.mockResolvedValueOnce({ success: true }); + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'unknown', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Trying to duplicate object(s) with unsupported types: unknown:my-pattern"` + ); + }); + + it('duplicate index pattern and dashboard into a workspace successfully', async () => { + const targetWorkspace = 'target_workspace_id'; + const savedObjects = [mockIndexPattern, mockDashboard]; + clientMock.get.mockResolvedValueOnce({ success: true }); + exportSavedObjectsToStream.mockResolvedValueOnce(createListStream(savedObjects)); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: savedObjects.map((obj) => ({ ...obj, workspaces: [targetWorkspace] })), + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { + type: mockIndexPattern.type, + id: mockIndexPattern.id, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, + }, + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + }); + + it('duplicate a saved object failed if its references are missing', async () => { + const targetWorkspace = 'target_workspace_id'; + const savedObjects = [mockVisualization]; + const exportDetail = { + exportedCount: 2, + missingRefCount: 1, + missingReferences: [{ type: 'index-pattern', id: 'my-pattern' }], + }; + clientMock.get.mockResolvedValueOnce({ success: true }); + exportSavedObjectsToStream.mockResolvedValueOnce( + createListStream(...savedObjects, exportDetail) + ); + + const error = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [{ ...mockIndexPattern, error }], + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'visualization', + id: 'my-visualization', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body).toEqual({ + success: false, + successCount: 0, + errors: [ + { + id: 'my-visualization', + type: 'visualization', + title: 'Test visualization', + meta: { title: 'Test visualization', icon: 'visualization-icon' }, + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'my-pattern' }], + }, + }, + ], + }); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 4ef7aeb13d5e..66b7032a003a 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -264,6 +264,152 @@ describe('workspace service api integration test', () => { expect(listResult.body.result.total).toEqual(1); }); }); + + describe('Duplicate saved objects APIs', () => { + const mockIndexPattern = { + type: 'index-pattern', + id: 'my-pattern', + attributes: { title: 'my-pattern-*' }, + references: [], + }; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + + afterAll(async () => { + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + const savedObjectsRepository = osd.coreStart.savedObjects.createInternalRepository([ + WORKSPACE_TYPE, + ]); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + // this will delete reserved workspace + savedObjectsRepository.delete(WORKSPACE_TYPE, item.id) + ) + ); + }); + + it('requires objects', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({}) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.objects]: expected value of type [array] but got [undefined]"` + ); + }); + + it('requires target workspace', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.targetWorkspace]: expected value of type [string] but got [undefined]"` + ); + }); + + it('duplicate unsupported objects', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'unknown', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Trying to duplicate object(s) with unsupported types: unknown:my-pattern"` + ); + }); + + it('target workspace does not exist', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Get target workspace test_workspace error: Saved object [workspace/test_workspace] not found"` + ); + }); + + it('duplicate index pattern and dashboard into a workspace successfully', async () => { + const createWorkspaceResult: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + expect(createWorkspaceResult.body.success).toEqual(true); + expect(typeof createWorkspaceResult.body.result.id).toBe('string'); + + const createSavedObjectsResult = await osdTestServer.request + .post(root, '/api/saved_objects/_bulk_create') + .send([mockIndexPattern, mockDashboard]) + .expect(200); + expect(createSavedObjectsResult.body.saved_objects.length).toBe(2); + + const targetWorkspace = createWorkspaceResult.body.result.id; + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body.success).toEqual(true); + expect(result.body.successCount).toEqual(2); + }); + }); }); describe('workspace service api integration test when savedObjects.permission.enabled equal true', () => { diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index df1ece8ef469..d86a22296788 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -78,6 +78,7 @@ export class WorkspacePlugin implements Plugin { + router.post( + { + path: `${WORKSPACES_API_BASE_URL}/_duplicate_saved_objects`, + validate: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + includeReferencesDeep: schema.boolean({ defaultValue: true }), + targetWorkspace: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObjectsClient = context.core.savedObjects.client; + const { objects, includeReferencesDeep, targetWorkspace } = req.body; + + // need to access the registry for type validation, can't use the schema for this + const supportedTypes = context.core.savedObjects.typeRegistry + .getImportableAndExportableTypes() + .map((t) => t.name); + + const invalidObjects = objects.filter((obj) => !supportedTypes.includes(obj.type)); + if (invalidObjects.length) { + return res.badRequest({ + body: { + message: `Trying to duplicate object(s) with unsupported types: ${invalidObjects + .map((obj) => `${obj.type}:${obj.id}`) + .join(', ')}`, + }, + }); + } + + // check whether the target workspace exists or not + const getTargetWorkspaceResult = await client.get( + { + context, + request: req, + logger, + }, + targetWorkspace + ); + if (!getTargetWorkspaceResult.success) { + return res.badRequest({ + body: { + message: `Get target workspace ${targetWorkspace} error: ${getTargetWorkspaceResult.error}`, + }, + }); + } + + // fetch all the details of the specified saved objects + const objectsListStream = await exportSavedObjectsToStream({ + savedObjectsClient, + objects, + exportSizeLimit: maxImportExportSize, + includeReferencesDeep, + excludeExportDetails: true, + }); + + // import the saved objects into the target workspace + const result = await importSavedObjectsFromStream({ + savedObjectsClient: context.core.savedObjects.client, + typeRegistry: context.core.savedObjects.typeRegistry, + readStream: objectsListStream, + objectLimit: maxImportExportSize, + overwrite: false, + createNewCopies: true, + workspaces: [targetWorkspace], + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 3e08fc298ea9..b49bb2893575 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -8,8 +8,9 @@ import { CoreSetup, Logger, PrincipalType, ACL } from '../../../../core/server'; import { WorkspacePermissionMode } from '../../common/constants'; import { IWorkspaceClientImpl, WorkspaceAttributeWithPermission } from '../types'; import { SavedObjectsPermissionControlContract } from '../permission_control/client'; +import { registerDuplicateRoute } from './duplicate'; -const WORKSPACES_API_BASE_URL = '/api/workspaces'; +export const WORKSPACES_API_BASE_URL = '/api/workspaces'; const workspacePermissionMode = schema.oneOf([ schema.literal(WorkspacePermissionMode.Read), @@ -42,12 +43,14 @@ export function registerRoutes({ client, logger, http, + maxImportExportSize, permissionControlClient, isPermissionControlEnabled, }: { client: IWorkspaceClientImpl; logger: Logger; http: CoreSetup['http']; + maxImportExportSize: number; permissionControlClient?: SavedObjectsPermissionControlContract; isPermissionControlEnabled: boolean; }) { @@ -207,4 +210,7 @@ export function registerRoutes({ return res.ok({ body: result }); }) ); + + // duplicate saved objects among workspaces + registerDuplicateRoute(router, logger, client, maxImportExportSize); }