From 9a97b436b11820ff7df4e3e456e9aea3a6005593 Mon Sep 17 00:00:00 2001 From: Tianle Huang <60111637+tianleh@users.noreply.github.com> Date: Tue, 16 Apr 2024 09:17:44 -0700 Subject: [PATCH 01/10] fix dynamic config API calls to pass correct input (#6474) * update dynamic API calls to pass correct input Signed-off-by: Tianle Huang * add unit tests Signed-off-by: Tianle Huang * add changelog Signed-off-by: Tianle Huang * revert yml Signed-off-by: Tianle Huang --------- Signed-off-by: Tianle Huang --- CHANGELOG.md | 1 + .../server/routes/index.test.ts | 86 +++++++++++++++++-- .../application_config/server/routes/index.ts | 35 ++++---- 3 files changed, 96 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c823709af27..06eccf2d30ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [BUG][Multiple Datasource]Read hideLocalCluster setting from yml and set in data source selector and data source menu ([#6361](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6361)) - [BUG][Multiple Datasource] Refactor read-only component to cover more edge cases ([#6416](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6416)) - [BUG] Fix for checkForFunctionProperty so that order does not matter ([#6248](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6248)) +- [Dynamic Configurations] Fix dynamic config API calls to pass correct input ([#6474](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6474)) - [BUG][Multiple Datasource] Validation succeed as long as status code in response is 200 ([#6399](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6399)) - [BUG][Multiple Datasource] Add validation for title length to be no longer than 32 characters [#6452](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6452)) diff --git a/src/plugins/application_config/server/routes/index.test.ts b/src/plugins/application_config/server/routes/index.test.ts index 0aa161bf560f..37fc7981ea16 100644 --- a/src/plugins/application_config/server/routes/index.test.ts +++ b/src/plugins/application_config/server/routes/index.test.ts @@ -79,6 +79,8 @@ describe('application config routes', () => { getConfig: jest.fn().mockReturnValue(configurations), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = {}; const okResponse = { @@ -91,7 +93,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetConfig(client, request, response, logger); + const returnedResponse = await handleGetConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(okResponse); @@ -100,6 +107,8 @@ describe('application config routes', () => { value: configurations, }, }); + + expect(getConfigurationClient).toBeCalledWith(request); }); it('return error response when client throws error', async () => { @@ -111,6 +120,8 @@ describe('application config routes', () => { }), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = {}; const response = { @@ -119,7 +130,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetConfig(client, request, response, logger); + const returnedResponse = await handleGetConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(ERROR_RESPONSE); @@ -131,6 +147,7 @@ describe('application config routes', () => { }); expect(logger.error).toBeCalledWith(error); + expect(getConfigurationClient).toBeCalledWith(request); }); }); @@ -140,6 +157,8 @@ describe('application config routes', () => { getEntityConfig: jest.fn().mockReturnValue(ENTITY_VALUE), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const okResponse = { statusCode: 200, }; @@ -156,7 +175,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetEntityConfig(client, request, response, logger); + const returnedResponse = await handleGetEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(okResponse); @@ -165,6 +189,8 @@ describe('application config routes', () => { value: ENTITY_VALUE, }, }); + + expect(getConfigurationClient).toBeCalledWith(request); }); it('return error response when client throws error', async () => { @@ -176,6 +202,8 @@ describe('application config routes', () => { }), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = { params: { entity: ENTITY_NAME, @@ -188,7 +216,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetEntityConfig(client, request, response, logger); + const returnedResponse = await handleGetEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(ERROR_RESPONSE); @@ -200,6 +233,8 @@ describe('application config routes', () => { }); expect(logger.error).toBeCalledWith(error); + + expect(getConfigurationClient).toBeCalledWith(request); }); }); @@ -209,6 +244,8 @@ describe('application config routes', () => { updateEntityConfig: jest.fn().mockReturnValue(ENTITY_NEW_VALUE), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const okResponse = { statusCode: 200, }; @@ -228,7 +265,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger); + const returnedResponse = await handleUpdateEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(okResponse); @@ -241,6 +283,8 @@ describe('application config routes', () => { }); expect(logger.error).not.toBeCalled(); + + expect(getConfigurationClient).toBeCalledWith(request); }); it('return error response when client fails', async () => { @@ -252,6 +296,8 @@ describe('application config routes', () => { }), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = { params: { entity: ENTITY_NAME, @@ -267,7 +313,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger); + const returnedResponse = await handleUpdateEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(ERROR_RESPONSE); @@ -279,6 +330,8 @@ describe('application config routes', () => { }); expect(logger.error).toBeCalledWith(error); + + expect(getConfigurationClient).toBeCalledWith(request); }); }); @@ -288,6 +341,8 @@ describe('application config routes', () => { deleteEntityConfig: jest.fn().mockReturnValue(ENTITY_NAME), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const okResponse = { statusCode: 200, }; @@ -304,7 +359,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger); + const returnedResponse = await handleDeleteEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(okResponse); @@ -317,6 +377,7 @@ describe('application config routes', () => { }); expect(logger.error).not.toBeCalled(); + expect(getConfigurationClient).toBeCalledWith(request); }); it('return error response when client fails', async () => { @@ -328,6 +389,8 @@ describe('application config routes', () => { }), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = { params: { entity: ENTITY_NAME, @@ -340,7 +403,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger); + const returnedResponse = await handleDeleteEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(ERROR_RESPONSE); @@ -352,6 +420,8 @@ describe('application config routes', () => { }); expect(logger.error).toBeCalledWith(error); + + expect(getConfigurationClient).toBeCalledWith(request); }); }); }); diff --git a/src/plugins/application_config/server/routes/index.ts b/src/plugins/application_config/server/routes/index.ts index b6ec638e1aa9..82c9a98bc445 100644 --- a/src/plugins/application_config/server/routes/index.ts +++ b/src/plugins/application_config/server/routes/index.ts @@ -6,7 +6,6 @@ import { schema } from '@osd/config-schema'; import { IRouter, - IScopedClusterClient, Logger, OpenSearchDashboardsRequest, OpenSearchDashboardsResponseFactory, @@ -15,7 +14,7 @@ import { ConfigurationClient } from '../types'; export function defineRoutes( router: IRouter, - getConfigurationClient: (configurationClient: IScopedClusterClient) => ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, logger: Logger ) { router.get( @@ -24,9 +23,7 @@ export function defineRoutes( validate: false, }, async (context, request, response) => { - const client = getConfigurationClient(context.core.opensearch.client); - - return await handleGetConfig(client, request, response, logger); + return await handleGetConfig(getConfigurationClient, request, response, logger); } ); router.get( @@ -39,9 +36,7 @@ export function defineRoutes( }, }, async (context, request, response) => { - const client = getConfigurationClient(context.core.opensearch.client); - - return await handleGetEntityConfig(client, request, response, logger); + return await handleGetEntityConfig(getConfigurationClient, request, response, logger); } ); router.post( @@ -57,9 +52,7 @@ export function defineRoutes( }, }, async (context, request, response) => { - const client = getConfigurationClient(context.core.opensearch.client); - - return await handleUpdateEntityConfig(client, request, response, logger); + return await handleUpdateEntityConfig(getConfigurationClient, request, response, logger); } ); router.delete( @@ -72,21 +65,21 @@ export function defineRoutes( }, }, async (context, request, response) => { - const client = getConfigurationClient(context.core.opensearch.client); - - return await handleDeleteEntityConfig(client, request, response, logger); + return await handleDeleteEntityConfig(getConfigurationClient, request, response, logger); } ); } export async function handleGetEntityConfig( - client: ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger ) { logger.info(`Received a request to get entity config for ${request.params.entity}.`); + const client = getConfigurationClient(request); + try { const result = await client.getEntityConfig(request.params.entity, { headers: request.headers, @@ -103,7 +96,7 @@ export async function handleGetEntityConfig( } export async function handleUpdateEntityConfig( - client: ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger @@ -112,6 +105,8 @@ export async function handleUpdateEntityConfig( `Received a request to update entity ${request.params.entity} with new value ${request.body.newValue}.` ); + const client = getConfigurationClient(request); + try { const result = await client.updateEntityConfig(request.params.entity, request.body.newValue, { headers: request.headers, @@ -128,13 +123,15 @@ export async function handleUpdateEntityConfig( } export async function handleDeleteEntityConfig( - client: ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger ) { logger.info(`Received a request to delete entity ${request.params.entity}.`); + const client = getConfigurationClient(request); + try { const result = await client.deleteEntityConfig(request.params.entity, { headers: request.headers, @@ -151,13 +148,15 @@ export async function handleDeleteEntityConfig( } export async function handleGetConfig( - client: ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger ) { logger.info('Received a request to get all configurations.'); + const client = getConfigurationClient(request); + try { const result = await client.getConfig({ headers: request.headers }); return response.ok({ From a1c8b8c437e34d186f3bc44436f71b62371432f1 Mon Sep 17 00:00:00 2001 From: Zhongnan Su Date: Tue, 16 Apr 2024 09:30:56 -0700 Subject: [PATCH 02/10] [MD] Add dropdown header to Data source single selector (#6431) --- CHANGELOG.md | 1 + .../opensearch_dashboards.json | 2 +- .../create_data_source_menu.test.tsx | 15 +++- .../create_data_source_menu.tsx | 10 ++- .../data_source_menu/data_source_menu.tsx | 4 +- .../components/data_source_menu/types.ts | 2 + .../data_source_selectable.test.tsx.snap | 57 +++++++++++- .../data_source_selectable.test.tsx | 51 ++++++++++- .../data_source_selectable.tsx | 25 +++++- .../data_source_selector.test.tsx | 1 - .../drop_down_header.test.tsx.snap | 86 +++++++++++++++++++ .../drop_down_header/drop_down_header.scss | 3 + .../drop_down_header.test.tsx | 67 +++++++++++++++ .../drop_down_header/drop_down_header.tsx | 50 +++++++++++ .../components/drop_down_header/index.ts | 6 ++ .../public/components/utils.ts | 19 ++++ .../data_source_management/public/plugin.ts | 4 +- 17 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06eccf2d30ec..4a8dca630e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [CSP Handler] Update CSP handler to only query and modify frame ancestors instead of all CSP directives ([#6398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6398)) - [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400)) - [Workspace] Support workspace in saved objects client in server side. ([#6365](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6365)) +- [MD] Add dropdown header to data source single selector ([#6431](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6431)) ### 🐛 Bug Fixes diff --git a/src/plugins/data_source_management/opensearch_dashboards.json b/src/plugins/data_source_management/opensearch_dashboards.json index cfcfdd2ce430..824f9eacc9f6 100644 --- a/src/plugins/data_source_management/opensearch_dashboards.json +++ b/src/plugins/data_source_management/opensearch_dashboards.json @@ -5,6 +5,6 @@ "ui": true, "requiredPlugins": ["management", "dataSource", "indexPatternManagement"], "optionalPlugins": [], - "requiredBundles": ["opensearchDashboardsReact", "dataSource"], + "requiredBundles": ["opensearchDashboardsReact", "dataSource", "opensearchDashboardsUtils"], "extraPublicDirs": ["public/components/utils"] } diff --git a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx index 6d7200b182c5..df299687928b 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx @@ -5,18 +5,29 @@ import { createDataSourceMenu } from './create_data_source_menu'; import { MountPoint, SavedObjectsClientContract } from '../../../../../core/public'; -import { coreMock, notificationServiceMock } from '../../../../../core/public/mocks'; +import { + applicationServiceMock, + coreMock, + notificationServiceMock, +} from '../../../../../core/public/mocks'; import React from 'react'; -import { act, getByText, render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { DataSourceComponentType, DataSourceSelectableConfig } from './types'; import { ReactWrapper } from 'enzyme'; import { mockDataSourcePluginSetupWithShowLocalCluster } from '../../mocks'; +import * as utils from '../utils'; describe('create data source menu', () => { let client: SavedObjectsClientContract; const notifications = notificationServiceMock.createStartContract(); const { uiSettings } = coreMock.createSetup(); + beforeAll(() => { + jest + .spyOn(utils, 'getApplication') + .mockImplementation(() => applicationServiceMock.createStartContract()); + }); + beforeEach(() => { client = { find: jest.fn().mockResolvedValue([]), diff --git a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx index 56fd7a7a7cb3..51fcb0db0857 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx @@ -10,11 +10,13 @@ import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { DataSourceMenu } from './data_source_menu'; import { DataSourceMenuProps } from './types'; import { MountPointPortal } from '../../../../opensearch_dashboards_react/public'; +import { getApplication } from '../utils'; export function createDataSourceMenu( uiSettings: IUiSettingsClient, dataSourcePluginSetup: DataSourcePluginSetup ) { + const application = getApplication(); return (props: DataSourceMenuProps) => { const { hideLocalCluster } = dataSourcePluginSetup; if (props.setMenuMountPoint) { @@ -25,13 +27,19 @@ export function createDataSourceMenu( {...props} uiSettings={uiSettings} hideLocalCluster={hideLocalCluster} + application={application} /> ); } return ( - + ); }; } diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx index 9511090e6c5a..bc645a0b885f 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx @@ -19,8 +19,7 @@ import { import { DataSourceSelectable } from '../data_source_selectable'; export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null { - const { componentType, componentConfig, uiSettings, hideLocalCluster } = props; - + const { componentType, componentConfig, uiSettings, hideLocalCluster, application } = props; function renderDataSourceView(config: DataSourceViewConfig): ReactElement | null { const { activeOption, @@ -81,6 +80,7 @@ export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | hideLocalCluster={hideLocalCluster || false} fullWidth={fullWidth} uiSettings={uiSettings} + application={application} /> ); } diff --git a/src/plugins/data_source_management/public/components/data_source_menu/types.ts b/src/plugins/data_source_management/public/components/data_source_menu/types.ts index a37d7d0dd0c3..e5f34a3a2979 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/types.ts +++ b/src/plugins/data_source_management/public/components/data_source_menu/types.ts @@ -8,6 +8,7 @@ import { SavedObjectsClientContract, SavedObject, IUiSettingsClient, + ApplicationStart, } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; @@ -32,6 +33,7 @@ export interface DataSourceMenuProps { componentConfig: T; hideLocalCluster?: boolean; uiSettings?: IUiSettingsClient; + application?: ApplicationStart; setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; } diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap index e49222c37d7c..4601e70491d2 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap @@ -24,6 +24,7 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -37,6 +38,12 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` color="transparent" paddingSize="s" > + + @@ -70,6 +77,7 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -105,6 +113,7 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -118,6 +127,12 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde color="transparent" paddingSize="s" > + + @@ -130,6 +145,7 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -165,6 +181,7 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -178,6 +195,12 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd color="transparent" paddingSize="s" > + + @@ -190,6 +213,7 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -273,6 +297,35 @@ Object {
+
+
+ DATA SOURCES + ( + 3 + ) +
+
+
+ +
+
+
@@ -281,7 +334,7 @@ Object { data-test-subj="dataSourceSelectable" >
{ let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -409,7 +410,7 @@ describe('DataSourceSelectable', () => { component.instance().componentDidMount!(); await nextTick(); const optionsProp = component.find(EuiSelectable).prop('options'); - expect(optionsProp[0]).toEqual(opensearchClusterGroupLabel); + expect(optionsProp[0]).toEqual(dataSourceOptionGroupLabel.opensearchCluster); }); it('should not render opensearch cluster group label, when there is no option availiable', async () => { @@ -431,4 +432,50 @@ describe('DataSourceSelectable', () => { const optionsProp = component.find(EuiSelectable).prop('options'); expect(optionsProp).toEqual([]); }); + + it('should render group lablel normally after onChange', async () => { + const onSelectedDataSource = jest.fn(); + component = shallow( + + ); + const componentInstance = component.instance(); + + componentInstance.componentDidMount!(); + await nextTick(); + const optionsPropBefore = component.find(EuiSelectable).prop('options'); + expect(optionsPropBefore).toEqual([ + dataSourceOptionGroupLabel.opensearchCluster, + { + id: 'test1', + label: 'test1', + checked: 'on', + }, + { + id: 'test2', + label: 'test2', + }, + { + id: 'test3', + label: 'test3', + }, + ]); + componentInstance.onChange([ + dataSourceOptionGroupLabel.opensearchCluster, + { id: 'test2', label: 'test2', checked: 'on' }, + ]); + await nextTick(); + const optionsPropAfter = component.find(EuiSelectable).prop('options'); + expect(optionsPropAfter).toEqual([ + dataSourceOptionGroupLabel.opensearchCluster, + { id: 'test2', label: 'test2', checked: 'on' }, + ]); + }); }); diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx index 89f3d908dfe8..cf7c88526065 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx @@ -12,19 +12,27 @@ import { EuiButtonEmpty, EuiSelectable, EuiSpacer, + EuiHorizontalRule, } from '@elastic/eui'; import { + ApplicationStart, IUiSettingsClient, SavedObjectsClientContract, ToastsStart, } from 'opensearch-dashboards/public'; -import { getDataSourcesWithFields, getDefaultDataSource, getFilteredDataSources } from '../utils'; +import { + dataSourceOptionGroupLabel, + getDataSourcesWithFields, + getDefaultDataSource, + getFilteredDataSources, +} from '../utils'; import { LocalCluster } from '../data_source_selector/data_source_selector'; import { SavedObject } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; import { DataSourceGroupLabelOption, DataSourceOption } from '../data_source_menu/types'; import { DataSourceItem } from '../data_source_item'; import './data_source_selectable.scss'; +import { DataSourceDropDownHeader } from '../drop_down_header'; interface DataSourceSelectableProps { savedObjectsClient: SavedObjectsClientContract; @@ -33,6 +41,7 @@ interface DataSourceSelectableProps { disabled: boolean; hideLocalCluster: boolean; fullWidth: boolean; + application?: ApplicationStart; selectedOption?: DataSourceOption[]; dataSourceFilter?: (dataSource: SavedObject) => boolean; uiSettings?: IUiSettingsClient; @@ -193,9 +202,12 @@ export class DataSourceSelectable extends React.Component< onChange(options: DataSourceOption[]) { if (!this._isMounted) return; + const optionsWithoutGroupLabel = options.filter( + (option) => !option.hasOwnProperty('isGroupLabel') + ); const selectedDataSource = options.find(({ checked }) => checked); - this.setState({ dataSourceOptions: options }); + this.setState({ dataSourceOptions: optionsWithoutGroupLabel }); if (selectedDataSource) { this.setState({ @@ -213,7 +225,7 @@ export class DataSourceSelectable extends React.Component< if (dataSourceOptions.length === 0) { optionsWithGroupLabel = []; } else { - optionsWithGroupLabel = [opensearchClusterGroupLabel, ...dataSourceOptions]; + optionsWithGroupLabel = [dataSourceOptionGroupLabel.opensearchCluster, ...dataSourceOptions]; } return optionsWithGroupLabel; }; @@ -242,6 +254,7 @@ export class DataSourceSelectable extends React.Component< return ( + + this.onChange(newOptions)} diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx index af5086f35a50..4f8df2542059 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx @@ -14,7 +14,6 @@ import { mockResponseForSavedObjectsCalls, } from '../../mocks'; import { AuthType } from 'src/plugins/data_source/common/data_sources'; -import * as utils from '../utils'; import { EuiComboBox } from '@elastic/eui'; describe('DataSourceSelector', () => { diff --git a/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap b/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap new file mode 100644 index 000000000000..213c946d09b4 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSourceDropDownHeader should render correctly with the provided totalDataSourceCount 1`] = ` + + + + DATA SOURCES + ( + 5 + ) + +
+ + + Manage + + + + +`; + +exports[`DataSourceDropDownHeader should render the activeDataSourceCount/totalDataSourceCount when both provided 1`] = ` + + + +
+ +
+ DATA SOURCES + ( + 2/5 + ) +
+
+
+ +
+ + + +
+
+
+ + + +`; diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss new file mode 100644 index 000000000000..244ca77b90e1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss @@ -0,0 +1,3 @@ +.dataSourceDropDownHeaderInvisibleFocusable { + opacity: 0; +} diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx new file mode 100644 index 000000000000..920ad09271e1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { coreMock } from '../../../../../core/public/mocks'; +import { DSM_APP_ID } from '../../plugin'; +import { DataSourceDropDownHeader } from '.'; + +describe('DataSourceDropDownHeader', () => { + it('should render correctly with the provided totalDataSourceCount', () => { + const totalDataSourceCount = 5; + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render "DATA SOURCES" when totalDataSourceCount is greater than 1', () => { + const totalDataSourceCount = 5; + const wrapper = mount(); + expect(wrapper.text()).toContain('DATA SOURCES'); + }); + + it.each([1, 0])( + 'should render "DATA SOURCE" when totalDataSourceCount is %s', + (totalDataSourceCount) => { + const wrapper = mount( + + ); + expect(wrapper.text()).toContain('DATA SOURCE'); + } + ); + + it('should render the activeDataSourceCount/totalDataSourceCount when both provided', () => { + const totalDataSourceCount = 5; + const activeDataSourceCount = 2; + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.text()).toContain(`${activeDataSourceCount}/${totalDataSourceCount}`); + }); + + it('should call application.navigateToApp when the "Manage" link is clicked', () => { + const totalDataSourceCount = 5; + const applicationMock = coreMock.createStart().application; + const navigateToAppMock = applicationMock.navigateToApp; + + const wrapper = mount( + + ); + + wrapper.find('EuiLink').simulate('click'); + expect(navigateToAppMock).toHaveBeenCalledWith('management', { + path: `opensearch-dashboards/${DSM_APP_ID}`, + }); + }); +}); diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx new file mode 100644 index 000000000000..2eb457c47511 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './drop_down_header.scss'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import React from 'react'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { DSM_APP_ID } from '../../plugin'; + +interface DataSourceOptionItemProps { + totalDataSourceCount: number; + activeDataSourceCount?: number; + application?: ApplicationStart; +} + +export const DataSourceDropDownHeader: React.FC = ({ + activeDataSourceCount, + totalDataSourceCount, + application, +}) => { + const dataSourceCounterPrefix = totalDataSourceCount === 1 ? 'DATA SOURCE' : 'DATA SOURCES'; + const dataSourceCounter = + activeDataSourceCount !== undefined + ? `${activeDataSourceCount}/${totalDataSourceCount}` + : totalDataSourceCount; + + return ( + + + + {dataSourceCounterPrefix} ({dataSourceCounter}) + +
+ + + application?.navigateToApp('management', { + path: `opensearch-dashboards/${DSM_APP_ID}`, + }) + } + > + Manage + + + + + ); +}; diff --git a/src/plugins/data_source_management/public/components/drop_down_header/index.ts b/src/plugins/data_source_management/public/components/drop_down_header/index.ts new file mode 100644 index 000000000000..3fc657904637 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { DataSourceDropDownHeader } from './drop_down_header'; diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index e433cdff52c5..98c23cc140c3 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -9,8 +9,10 @@ import { SavedObject, IUiSettingsClient, ToastsStart, + ApplicationStart, } from 'src/core/public'; import { i18n } from '@osd/i18n'; +import { deepFreeze } from '@osd/std'; import { DataSourceAttributes, DataSourceTableItem, @@ -19,6 +21,8 @@ import { } from '../types'; import { AuthenticationMethodRegistry } from '../auth_registry'; import { DataSourceOption } from './data_source_menu/types'; +import { DataSourceGroupLabelOption } from './data_source_menu/types'; +import { createGetterSetter } from '../../../opensearch_dashboards_utils/public'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -279,3 +283,18 @@ export const extractRegisteredAuthTypeCredentials = ( return registeredCredentials; }; + +interface DataSourceOptionGroupLabel { + [key: string]: DataSourceGroupLabelOption; +} + +export const dataSourceOptionGroupLabel = deepFreeze>({ + opensearchCluster: { + id: 'opensearchClusterGroupLabel', + label: 'OpenSearch cluster', + isGroupLabel: true, + }, + // TODO: add other group labels if needed +}); + +export const [getApplication, setApplication] = createGetterSetter('Application'); diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index abcc532b8a7e..2461044a680b 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -21,6 +21,7 @@ import { noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod import { DataSourceSelectorProps } from './components/data_source_selector/data_source_selector'; import { createDataSourceMenu } from './components/data_source_menu/create_data_source_menu'; import { DataSourceMenuProps } from './components/data_source_menu'; +import { setApplication } from './components/utils'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -40,7 +41,7 @@ export interface DataSourceManagementPluginStart { getAuthenticationMethodRegistry: () => IAuthenticationMethodRegistry; } -const DSM_APP_ID = 'dataSources'; +export const DSM_APP_ID = 'dataSourceManagement'; export class DataSourceManagementPlugin implements @@ -111,6 +112,7 @@ export class DataSourceManagementPlugin public start(core: CoreStart) { this.started = true; + setApplication(core.application); return { getAuthenticationMethodRegistry: () => this.authMethodsRegistry, }; From 97355995c89a78bf45ebbdcbe3364fa8cc6eafe6 Mon Sep 17 00:00:00 2001 From: Yu Jin <112784385+yujin-emma@users.noreply.github.com> Date: Tue, 16 Apr 2024 11:18:44 -0700 Subject: [PATCH 03/10] [Multiple DataSource] Do not support import data source object to Local cluster when not enable data source (#6395) * WIP resolve import ds to lc Signed-off-by: yujin-emma * add test Signed-off-by: yujin-emma * fix typo Signed-off-by: yujin-emma * fix failed test Signed-off-by: yujin-emma * Update CHANGELOG.md Signed-off-by: yujin-emma * fix the failed test Signed-off-by: yujin-emma * remove unused snapshot Signed-off-by: yujin-emma * Update src/core/server/saved_objects/import/validate_object_id.ts Co-authored-by: Zhongnan Su Signed-off-by: Yu Jin <112784385+yujin-emma@users.noreply.github.com> * Update src/core/server/saved_objects/import/import_saved_objects.ts Co-authored-by: Zhongnan Su Signed-off-by: Yu Jin <112784385+yujin-emma@users.noreply.github.com> * address comments Signed-off-by: yujin-emma * fix test Signed-off-by: yujin-emma * remove unused comments Signed-off-by: yujin-emma * fix bootstrap filure Signed-off-by: yujin-emma --------- Signed-off-by: yujin-emma Signed-off-by: Yu Jin <112784385+yujin-emma@users.noreply.github.com> Co-authored-by: Zhongnan Su --- CHANGELOG.md | 1 + .../import/import_saved_objects.test.ts | 105 +++++++++++++++++- .../import/import_saved_objects.ts | 25 +++++ src/core/server/saved_objects/import/types.ts | 2 +- .../import/validate_object_id.test.ts | 59 ++++++++++ .../import/validate_object_id.ts | 40 +++++++ .../server/saved_objects/routes/import.ts | 4 + .../public/lib/import_file.ts | 6 +- .../objects_table/components/flyout.test.tsx | 1 + .../objects_table/components/flyout.tsx | 10 +- 10 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 src/core/server/saved_objects/import/validate_object_id.test.ts create mode 100644 src/core/server/saved_objects/import/validate_object_id.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8dca630e52..a578087fead9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231)) - [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171)) - [Multiple Datasource] Add multi selectable data source component ([#6211](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6211)) +- [Multiple Datasource] Do not support import data source object to Local cluster when not enable data source ([#6395](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6395)) - [Multiple Datasource] Refactor data source menu and interface to allow cleaner selection of component and related configurations ([#6256](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6256)) - [Multiple Datasource] Allow top nav menu to mount data source menu for use case when both menus are mounted ([#6268](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6268)) - [Workspace] Add create workspace page ([#6179](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6179)) diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index fff5b60c89cc..ea4ac66b1a0b 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -96,11 +96,12 @@ describe('#importSavedObjectsFromStream', () => { let savedObjectsClient: jest.Mocked; let typeRegistry: jest.Mocked; const namespace = 'some-namespace'; - const testDataSourceId = 'some-datasource'; + const testDataSourceId = uuidv4(); const setupOptions = ( createNewCopies: boolean = false, - dataSourceId: string | undefined = undefined + dataSourceId: string | undefined = undefined, + dataSourceEnabled: boolean | undefined = false ): SavedObjectsImportOptions => { readStream = new Readable(); savedObjectsClient = savedObjectsClientMock.create(); @@ -135,6 +136,17 @@ describe('#importSavedObjectsFromStream', () => { attributes: { title: 'some-title' }, }; }; + + const createDataSourceObject = (): SavedObject<{ + title: string; + }> => { + return { + type: 'data-source', + id: uuidv4(), + references: [], + attributes: { title: 'some-title' }, + }; + }; const createError = (): SavedObjectsImportError => { const title = 'some-title'; return { @@ -589,5 +601,94 @@ describe('#importSavedObjectsFromStream', () => { const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); expect(result).toEqual({ success: false, successCount: 0, errors: expectedErrors }); }); + + test('early return if import data source objects to non-MDS cluster', async () => { + const options = setupOptions(false, testDataSourceId, false); + const dsObj = createDataSourceObject(); + const dsExportedObj = createObject(testDataSourceId); + const collectedObjects = [dsObj, dsExportedObj]; + + const errors = [ + { + type: dsObj.type, + id: dsObj.id, + title: dsObj.attributes.title, + meta: { title: dsObj.attributes.title }, + error: { type: 'unsupported_type' }, + }, + { + type: dsExportedObj.type, + id: dsExportedObj.id, + title: dsExportedObj.attributes.title, + meta: { title: dsExportedObj.attributes.title }, + error: { type: 'unsupported_type' }, + }, + ]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + const result = await importSavedObjectsFromStream(options); + const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); + expect(result).toEqual({ success: false, successCount: 0, errors: expectedErrors }); + }); + + test('early return if import mixed non/data source objects to non-MDS cluster', async () => { + const options = setupOptions(false, testDataSourceId, false); + const dsObj = createDataSourceObject(); + const dsExportedObj = createObject(testDataSourceId); + const nonDsExportedObj = createObject(); + const collectedObjects = [dsObj, dsExportedObj, nonDsExportedObj]; + + const errors = [ + { + type: dsObj.type, + id: dsObj.id, + title: dsObj.attributes.title, + meta: { title: dsObj.attributes.title }, + error: { type: 'unsupported_type' }, + }, + { + type: dsExportedObj.type, + id: dsExportedObj.id, + title: dsExportedObj.attributes.title, + meta: { title: dsExportedObj.attributes.title }, + error: { type: 'unsupported_type' }, + }, + ]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + const result = await importSavedObjectsFromStream(options); + const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); + expect(result).toEqual({ success: false, successCount: 0, errors: expectedErrors }); + }); + + test('early return if import single data source objects to non-MDS cluster', async () => { + const options = setupOptions(false, testDataSourceId, false); + const dsObj = createDataSourceObject(); + const collectedObjects = [dsObj]; + + const errors = [ + { + type: dsObj.type, + id: dsObj.id, + title: dsObj.attributes.title, + meta: { title: dsObj.attributes.title }, + error: { type: 'unsupported_type' }, + }, + ]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + const result = await importSavedObjectsFromStream(options); + const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); + expect(result).toEqual({ success: false, successCount: 0, errors: expectedErrors }); + }); }); }); diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index e82b4e634e0f..cfd091149004 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -33,6 +33,7 @@ import { SavedObjectsImportError, SavedObjectsImportResponse, SavedObjectsImportOptions, + SavedObjectsImportUnsupportedTypeError, } from './types'; import { validateReferences } from './validate_references'; import { checkOriginConflicts } from './check_origin_conflicts'; @@ -40,6 +41,7 @@ import { createSavedObjects } from './create_saved_objects'; import { checkConflicts } from './check_conflicts'; import { regenerateIds } from './regenerate_ids'; import { checkConflictsForDataSource } from './check_conflict_for_data_source'; +import { isSavedObjectWithDataSource } from './validate_object_id'; /** * Import saved objects from given stream. See the {@link SavedObjectsImportOptions | options} for more @@ -58,6 +60,7 @@ export async function importSavedObjectsFromStream({ dataSourceId, dataSourceTitle, workspaces, + dataSourceEnabled, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); @@ -69,6 +72,28 @@ export async function importSavedObjectsFromStream({ supportedTypes, dataSourceId, }); + // if not enable data_source, throw error early + if (!dataSourceEnabled) { + const notSupportedErrors: SavedObjectsImportError[] = collectSavedObjectsResult.collectedObjects.reduce( + (errors: SavedObjectsImportError[], obj) => { + if (obj.type === 'data-source' || isSavedObjectWithDataSource(obj.id)) { + const error: SavedObjectsImportUnsupportedTypeError = { type: 'unsupported_type' }; + const { title } = obj.attributes; + errors.push({ error, type: obj.type, id: obj.id, title, meta: { title } }); + } + return errors; // Return the accumulator in each iteration + }, + [] + ); + if (notSupportedErrors?.length > 0) { + return { + successCount: 0, + success: false, + errors: notSupportedErrors, + }; + } + } + errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; /** Map of all IDs for objects that we are attempting to import; each value is empty by default */ let importIdMap = collectSavedObjectsResult.importIdMap; diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index a243e08f83e0..2db074119cad 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -189,7 +189,7 @@ export interface SavedObjectsImportOptions { createNewCopies: boolean; dataSourceId?: string; dataSourceTitle?: string; - /** if specified, will import in given workspaces */ + dataSourceEnabled?: boolean; workspaces?: SavedObjectsBaseOptions['workspaces']; } diff --git a/src/core/server/saved_objects/import/validate_object_id.test.ts b/src/core/server/saved_objects/import/validate_object_id.test.ts new file mode 100644 index 000000000000..2f0cb3c6487a --- /dev/null +++ b/src/core/server/saved_objects/import/validate_object_id.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isSavedObjectWithDataSource } from './validate_object_id'; + +describe('isObjectWithDataSource', () => { + test('should return false for valid object with data source ID but in wrong format', () => { + // Valid ID with two parts separated by underscore, and both parts being UUIDs + const inValidId = 'invalid_uuid_1234-invalid_uuid_5678'; + expect(isSavedObjectWithDataSource(inValidId)).toBe(false); + }); + + test('should return false for invalid IDs', () => { + // Missing underscore + const invalidId1 = 'missingunderscore'; + expect(isSavedObjectWithDataSource(invalidId1)).toBe(false); + + // Invalid UUID in the second part + const invalidId2 = 'valid_uuid_1234-invalid_uuid'; + expect(isSavedObjectWithDataSource(invalidId2)).toBe(false); + + // Missing second part + const invalidId3 = 'valid_uuid_1234'; + expect(isSavedObjectWithDataSource(invalidId3)).toBe(false); + + // More than two parts + const invalidId4 = 'valid_uuid_1234-valid_uuid_5678-extra_part'; + expect(isSavedObjectWithDataSource(invalidId4)).toBe(false); + }); + + test('should return false for non-UUID parts', () => { + // First part is not a UUID + const invalidId1 = 'not_a_uuid_valid_uuid_1234'; + expect(isSavedObjectWithDataSource(invalidId1)).toBe(false); + + // Second part is not a UUID + const invalidId2 = 'valid_uuid_1234_not_a_uuid'; + expect(isSavedObjectWithDataSource(invalidId2)).toBe(false); + + // Both parts are not UUIDs + const invalidId3 = 'not_a_uuid_not_a_uuid'; + expect(isSavedObjectWithDataSource(invalidId3)).toBe(false); + }); + + test('should return false for string with underscore but not with UUID', () => { + // First part is not a UUID + const invalidId = 'saved_object_with_index_pattern_conflict'; + expect(isSavedObjectWithDataSource(invalidId)).toBe(false); + }); + + test('should return false for string with underscore but with three UUIDs', () => { + // First part is not a UUID + const invalidId = + '7cbd2350-2223-11e8-b802-5bcf64c2cfb4_7cbd2350-2223-11e8-b802-5bcf64c2cfb4_7cbd2350-2223-11e8-b802-5bcf64c2cfb4'; + expect(isSavedObjectWithDataSource(invalidId)).toBe(false); + }); +}); diff --git a/src/core/server/saved_objects/import/validate_object_id.ts b/src/core/server/saved_objects/import/validate_object_id.ts new file mode 100644 index 000000000000..9a496c4d572a --- /dev/null +++ b/src/core/server/saved_objects/import/validate_object_id.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * When enable multiple data source, exported objects from a data source will maintain object id like + * "69a34b00-9ee8-11e7-8711-e7a007dcef99_7cbd2350-2223-11e8-b802-5bcf64c2cfb4" + * two UUIDs are connected with a underscore, + * before the underscore, the UUID represents the data source + * after the underscore, the UUID is the original object id + * when disable multiple data source, the exported object from local cluster will look like 7cbd2350-2223-11e8-b802-5bcf64c2cfb4 + * we can use this format to tell out whether a single object is exported from MDS enabled/disabled cluster + * + * This file to going to group some validate function to tell source of object based on the object id + */ + +/** + * + * @param candidate: string without underscore + * @returns + */ +const isUUID = (candidate: string): boolean => { + // Regular expression pattern for UUID + const uuidPattern: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidPattern.test(candidate); +}; + +/** + * + * @param id single object id + * @returns + */ +export const isSavedObjectWithDataSource = (id: string): boolean => { + const idParts = id.split('_'); + /** + * check with the + */ + return idParts && idParts.length === 2 && idParts.every(isUUID); +}; diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 1fc739ea168c..a2a5bdcacd7a 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -64,6 +64,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) workspaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), + dataSourceEnabled: schema.maybe(schema.boolean({ defaultValue: false })), }, { validate: (object) => { @@ -116,6 +117,8 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) workspaces = [workspaces]; } + const dataSourceEnabled = req.query.dataSourceEnabled; + const result = await importSavedObjectsFromStream({ savedObjectsClient: context.core.savedObjects.client, typeRegistry: context.core.savedObjects.typeRegistry, @@ -126,6 +129,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) dataSourceId, dataSourceTitle, workspaces, + dataSourceEnabled, }); return res.ok({ body: result }); diff --git a/src/plugins/saved_objects_management/public/lib/import_file.ts b/src/plugins/saved_objects_management/public/lib/import_file.ts index 3753a8251e10..f5156cd94f1d 100644 --- a/src/plugins/saved_objects_management/public/lib/import_file.ts +++ b/src/plugins/saved_objects_management/public/lib/import_file.ts @@ -41,7 +41,8 @@ export async function importFile( http: HttpStart, file: File, { createNewCopies, overwrite }: ImportMode, - selectedDataSourceId?: string + selectedDataSourceId?: string, + dataSourceEnabled?: boolean ) { const formData = new FormData(); formData.append('file', file); @@ -49,6 +50,9 @@ export async function importFile( if (selectedDataSourceId) { query.dataSourceId = selectedDataSourceId; } + if (dataSourceEnabled) { + query.dataSourceEnabled = dataSourceEnabled; + } return await http.post('/api/saved_objects/_import', { body: formData, headers: { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx index 575741708f1e..ffefc806a979 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx @@ -231,6 +231,7 @@ describe('Flyout', () => { createNewCopies: true, overwrite: true, }, + undefined, undefined ); expect(component.state()).toMatchObject({ diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index 586a573ffb53..03b15ada3201 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -189,13 +189,19 @@ export class Flyout extends Component { * Does the initial import of a file, resolveImportErrors then handles errors and retries */ import = async () => { - const { http } = this.props; + const { http, dataSourceEnabled } = this.props; const { file, importMode, selectedDataSourceId } = this.state; this.setState({ status: 'loading', error: undefined }); // Import the file try { - const response = await importFile(http, file!, importMode, selectedDataSourceId); + const response = await importFile( + http, + file!, + importMode, + selectedDataSourceId, + dataSourceEnabled + ); this.setState(processImportResponse(response), () => { // Resolve import errors right away if there's no index patterns to match // This will ask about overwriting each object, etc From b9c703bc39c915cb5c240f0930acd9d1a43f4e68 Mon Sep 17 00:00:00 2001 From: Lu Yu Date: Tue, 16 Apr 2024 11:26:26 -0700 Subject: [PATCH 04/10] [Multiple Datasource] Add error state to all data source menu components to show error component and consolidate all fetch errors (#6440) * add error state to all components Signed-off-by: Lu Yu * add change log Signed-off-by: Lu Yu * resolve conflict and fix tests Signed-off-by: Lu Yu * move import up Signed-off-by: Lu Yu --------- Signed-off-by: Lu Yu Co-authored-by: ZilongX <99905560+ZilongX@users.noreply.github.com> --- CHANGELOG.md | 1 + .../data_source_aggregated_view.tsx | 20 ++++--- .../data_source_error_menu.tsx | 16 +++++ .../data_source_error_menu/index.ts | 5 ++ .../create_data_source_menu.test.tsx.snap | 60 +++++-------------- .../components/data_source_menu/types.ts | 4 ++ .../data_source_multi_selectable.tsx | 23 ++++--- .../data_source_selectable.test.tsx | 7 ++- .../data_source_selectable.tsx | 26 ++++++-- .../data_source_view.test.tsx.snap | 46 +------------- .../data_source_view/data_source_view.tsx | 23 +++---- .../public/components/utils.test.ts | 4 +- .../public/components/utils.ts | 25 ++++---- 13 files changed, 127 insertions(+), 133 deletions(-) create mode 100644 src/plugins/data_source_management/public/components/data_source_error_menu/data_source_error_menu.tsx create mode 100644 src/plugins/data_source_management/public/components/data_source_error_menu/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a578087fead9..ea2dd672f630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Dynamic Configurations] Improve dynamic configurations by adding cache and simplifying client fetch ([#6364](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6364)) - [CSP Handler] Update CSP handler to only query and modify frame ancestors instead of all CSP directives ([#6398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6398)) - [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400)) +- [Multiple Datasource] Add error state to all data source menu components to show error component and consolidate all fetch errors ([#6440](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6440)) - [Workspace] Support workspace in saved objects client in server side. ([#6365](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6365)) - [MD] Add dropdown header to data source single selector ([#6431](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6431)) diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx index 7c039c2f64f3..dda3da78363b 100644 --- a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx @@ -13,9 +13,11 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { SavedObjectsClientContract, ToastsStart } from 'opensearch-dashboards/public'; -import { getDataSourcesWithFields } from '../utils'; +import { getDataSourcesWithFields, handleDataSourceFetchError } from '../utils'; import { SavedObject } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; +import { DataSourceErrorMenu } from '../data_source_error_menu'; +import { DataSourceBaseState } from '../data_source_menu/types'; interface DataSourceAggregatedViewProps { savedObjectsClient: SavedObjectsClientContract; @@ -27,7 +29,7 @@ interface DataSourceAggregatedViewProps { displayAllCompatibleDataSources: boolean; } -interface DataSourceAggregatedViewState { +interface DataSourceAggregatedViewState extends DataSourceBaseState { isPopoverOpen: boolean; allDataSourcesIdToTitleMap: Map; } @@ -44,6 +46,7 @@ export class DataSourceAggregatedView extends React.Component< this.state = { isPopoverOpen: false, allDataSourcesIdToTitleMap: new Map(), + showError: false, }; } @@ -89,15 +92,18 @@ export class DataSourceAggregatedView extends React.Component< } }) .catch(() => { - this.props.notifications.addWarning( - i18n.translate('dataSource.fetchDataSourceError', { - defaultMessage: 'Unable to fetch existing data sources', - }) - ); + handleDataSourceFetchError(this.onError.bind(this), this.props.notifications); }); } + onError() { + this.setState({ showError: true }); + } + render() { + if (this.state.showError) { + return ; + } const button = ( { + return ( + <> + + Error + + ); +}; diff --git a/src/plugins/data_source_management/public/components/data_source_error_menu/index.ts b/src/plugins/data_source_management/public/components/data_source_error_menu/index.ts new file mode 100644 index 000000000000..1bc0b8eb36e3 --- /dev/null +++ b/src/plugins/data_source_management/public/components/data_source_error_menu/index.ts @@ -0,0 +1,5 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +export { DataSourceErrorMenu } from './data_source_error_menu'; diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap index 31ae3a99d9cd..c520768a6890 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap @@ -5,65 +5,33 @@ Object { "asFragment": [Function], "baseElement":
+
- + Error
, "container":
+
- + Error
, diff --git a/src/plugins/data_source_management/public/components/data_source_menu/types.ts b/src/plugins/data_source_management/public/components/data_source_menu/types.ts index e5f34a3a2979..483f08c524bc 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/types.ts +++ b/src/plugins/data_source_management/public/components/data_source_menu/types.ts @@ -28,6 +28,10 @@ export interface DataSourceBaseConfig { disabled?: boolean; } +export interface DataSourceBaseState { + showError: boolean; +} + export interface DataSourceMenuProps { componentType: DataSourceComponentType; componentConfig: T; diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx index 1a1d958b618c..25122a801e84 100644 --- a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx @@ -5,10 +5,11 @@ import React from 'react'; import { SavedObjectsClientContract, ToastsStart } from 'opensearch-dashboards/public'; -import { i18n } from '@osd/i18n'; import { IUiSettingsClient } from 'src/core/public'; import { DataSourceFilterGroup, SelectedDataSourceOption } from './data_source_filter_group'; -import { getDataSourcesWithFields } from '../utils'; +import { getDataSourcesWithFields, handleDataSourceFetchError } from '../utils'; +import { DataSourceBaseState } from '../data_source_menu/types'; +import { DataSourceErrorMenu } from '../data_source_error_menu'; export interface DataSourceMultiSeletableProps { savedObjectsClient: SavedObjectsClientContract; @@ -19,7 +20,7 @@ export interface DataSourceMultiSeletableProps { uiSettings?: IUiSettingsClient; } -interface DataSourceMultiSeletableState { +interface DataSourceMultiSeletableState extends DataSourceBaseState { dataSourceOptions: SelectedDataSourceOption[]; selectedOptions: SelectedDataSourceOption[]; defaultDataSource: string | null; @@ -38,6 +39,7 @@ export class DataSourceMultiSelectable extends React.Component< dataSourceOptions: [], selectedOptions: [], defaultDataSource: null, + showError: false, }; } @@ -84,14 +86,18 @@ export class DataSourceMultiSelectable extends React.Component< this.props.onSelectedDataSources(selectedOptions); } catch (error) { - this.props.notifications.addWarning( - i18n.translate('dataSource.fetchDataSourceError', { - defaultMessage: 'Unable to fetch existing data sources', - }) + handleDataSourceFetchError( + this.onError.bind(this), + this.props.notifications, + this.props.onSelectedDataSources ); } } + onError() { + this.setState({ showError: true }); + } + onChange(selectedOptions: SelectedDataSourceOption[]) { if (!this._isMounted) return; this.setState({ @@ -101,6 +107,9 @@ export class DataSourceMultiSelectable extends React.Component< } render() { + if (this.state.showError) { + return ; + } return ( { label: 'test2', }, ], + showError: false, }); containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); @@ -165,6 +166,7 @@ describe('DataSourceSelectable', () => { label: 'test2', }, ], + showError: false, }); expect(onSelectedDataSource).toBeCalledWith([{ id: 'test2', label: 'test2' }]); @@ -341,6 +343,7 @@ describe('DataSourceSelectable', () => { label: 'test2', }, ], + showError: false, }); }); @@ -362,12 +365,13 @@ describe('DataSourceSelectable', () => { const containerInstance = container.instance(); - expect(onSelectedDataSource).toBeCalledTimes(0); + expect(onSelectedDataSource).toBeCalledWith([]); expect(containerInstance.state).toEqual({ dataSourceOptions: [], defaultDataSource: null, isPopoverOpen: false, selectedOption: [], + showError: true, }); containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); @@ -388,6 +392,7 @@ describe('DataSourceSelectable', () => { label: 'test2', }, ], + showError: true, }); expect(onSelectedDataSource).toBeCalledWith([{ id: 'test2', label: 'test2' }]); diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx index cf7c88526065..47e54fae671f 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx @@ -25,11 +25,17 @@ import { getDataSourcesWithFields, getDefaultDataSource, getFilteredDataSources, + handleDataSourceFetchError, } from '../utils'; import { LocalCluster } from '../data_source_selector/data_source_selector'; import { SavedObject } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; -import { DataSourceGroupLabelOption, DataSourceOption } from '../data_source_menu/types'; +import { + DataSourceBaseState, + DataSourceGroupLabelOption, + DataSourceOption, +} from '../data_source_menu/types'; +import { DataSourceErrorMenu } from '../data_source_error_menu'; import { DataSourceItem } from '../data_source_item'; import './data_source_selectable.scss'; import { DataSourceDropDownHeader } from '../drop_down_header'; @@ -47,7 +53,7 @@ interface DataSourceSelectableProps { uiSettings?: IUiSettingsClient; } -interface DataSourceSelectableState { +interface DataSourceSelectableState extends DataSourceBaseState { dataSourceOptions: DataSourceOption[]; isPopoverOpen: boolean; selectedOption?: DataSourceOption[]; @@ -74,6 +80,7 @@ export class DataSourceSelectable extends React.Component< isPopoverOpen: false, selectedOption: [], defaultDataSource: null, + showError: false, }; this.onChange.bind(this); @@ -192,14 +199,18 @@ export class DataSourceSelectable extends React.Component< // handle default data source if there is no valid active option this.handleDefaultDataSource(dataSourceOptions, defaultDataSource); } catch (error) { - this.props.notifications.addWarning( - i18n.translate('dataSource.fetchDataSourceError', { - defaultMessage: 'Unable to fetch existing data sources', - }) + handleDataSourceFetchError( + this.onError.bind(this), + this.props.notifications, + this.props.onSelectedDataSources ); } } + onError() { + this.setState({ showError: true }); + } + onChange(options: DataSourceOption[]) { if (!this._isMounted) return; const optionsWithoutGroupLabel = options.filter( @@ -231,6 +242,9 @@ export class DataSourceSelectable extends React.Component< }; render() { + if (this.state.showError) { + return ; + } const button = ( <> `; -exports[`DataSourceView should call notification warning when there is data source fetch error 1`] = ` - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="dataSourceViewContextMenuPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > - - - -`; +exports[`DataSourceView should call notification warning when there is data source fetch error 1`] = ``; exports[`DataSourceView should render normally with local cluster not hidden 1`] = ` diff --git a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx index ab48e925d18f..420bb5927145 100644 --- a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx +++ b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx @@ -8,13 +8,14 @@ import { i18n } from '@osd/i18n'; import { EuiPopover, EuiButtonEmpty, EuiButtonIcon, EuiContextMenu } from '@elastic/eui'; import { SavedObjectsClientContract, ToastsStart } from 'opensearch-dashboards/public'; import { IUiSettingsClient } from 'src/core/public'; -import { DataSourceOption } from '../data_source_menu/types'; +import { DataSourceBaseState, DataSourceOption } from '../data_source_menu/types'; +import { MenuPanelItem } from '../../types'; +import { DataSourceErrorMenu } from '../data_source_error_menu'; import { getDataSourceById, handleDataSourceFetchError, handleNoAvailableDataSourceError, } from '../utils'; -import { MenuPanelItem } from '../../types'; import { LocalCluster } from '../constants'; interface DataSourceViewProps { @@ -28,7 +29,7 @@ interface DataSourceViewProps { onSelectedDataSources?: (dataSources: DataSourceOption[]) => void; } -interface DataSourceViewState { +interface DataSourceViewState extends DataSourceBaseState { selectedOption: DataSourceOption[]; isPopoverOpen: boolean; } @@ -42,6 +43,7 @@ export class DataSourceView extends React.Component; + } const { panels } = this.getPanels(); const button = ( diff --git a/src/plugins/data_source_management/public/components/utils.test.ts b/src/plugins/data_source_management/public/components/utils.test.ts index 397f55333ed2..cc553037188b 100644 --- a/src/plugins/data_source_management/public/components/utils.test.ts +++ b/src/plugins/data_source_management/public/components/utils.test.ts @@ -76,7 +76,9 @@ describe('DataSourceManagement: Utils.ts', () => { const { toasts } = notificationServiceMock.createStartContract(); test('should send warning when data source fetch failed', () => { - handleDataSourceFetchError(toasts); + const changeStateMock = jest.fn(); + handleDataSourceFetchError(changeStateMock, toasts); + expect(changeStateMock).toBeCalledWith({ showError: true }); expect(toasts.addWarning).toHaveBeenCalledWith(`Failed to fetch data source`); }); }); diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index 98c23cc140c3..a9f428f5cfa7 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -2,7 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - +import { i18n } from '@osd/i18n'; import { HttpStart, SavedObjectsClientContract, @@ -11,7 +11,6 @@ import { ToastsStart, ApplicationStart, } from 'src/core/public'; -import { i18n } from '@osd/i18n'; import { deepFreeze } from '@osd/std'; import { DataSourceAttributes, @@ -85,14 +84,6 @@ export async function setFirstDataSourceAsDefault( } } -export function handleDataSourceFetchError(notifications: ToastsStart) { - notifications.addWarning( - i18n.translate('dataSource.fetchDataSourceError', { - defaultMessage: `Failed to fetch data source`, - }) - ); -} - export function handleNoAvailableDataSourceError(notifications: ToastsStart) { notifications.addWarning( i18n.translate('dataSource.noAvailableDataSourceError', { @@ -284,6 +275,20 @@ export const extractRegisteredAuthTypeCredentials = ( return registeredCredentials; }; +export const handleDataSourceFetchError = ( + changeState: (state: { showError: boolean }) => void, + notifications: ToastsStart, + callback?: (ds: DataSourceOption[]) => void +) => { + changeState({ showError: true }); + if (callback) callback([]); + notifications.addWarning( + i18n.translate('dataSource.fetchDataSourceError', { + defaultMessage: 'Failed to fetch data source', + }) + ); +}; + interface DataSourceOptionGroupLabel { [key: string]: DataSourceGroupLabelOption; } From be0f9d555cab325736ac55dead9ea9f8c6fa00a7 Mon Sep 17 00:00:00 2001 From: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> Date: Tue, 16 Apr 2024 11:57:05 -0700 Subject: [PATCH 05/10] [MDS] TSVB Support (#6298) * Add MDS support to TSVB Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor datasource picker component Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Allow picker to persist state Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactored picker component params Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add unit tests Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add to CHANGELOG Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor components to use hideLocalCluster Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Remove Picker wrapper Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Update selector component and rename field to index name Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Address comments Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor to use different decideClient Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add optional arg Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Remove hidelocalcluster as a setting Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fixed case where local cluster is disabled but the datasource id could be local cluster Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add test for create data source picker handler Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --------- Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --- CHANGELOG.md | 1 + src/plugins/data/server/index.ts | 1 + .../data/server/index_patterns/index.ts | 1 + .../data/server/index_patterns/routes.ts | 2 +- .../data_source_selector.tsx | 2 + .../components/data_source_selector/index.ts | 2 +- .../data_source_management/public/index.ts | 2 +- .../vis_type_timeseries/common/constants.ts | 1 + .../vis_type_timeseries/common/vis_schema.ts | 1 + .../opensearch_dashboards.json | 2 +- .../components/annotations_editor.js | 7 ++- .../application/components/index_pattern.js | 53 +++++++++++++++++-- .../create_data_source_change_handler.test.ts | 49 +++++++++++++++++ .../lib/create_data_source_change_handler.ts | 16 ++++++ .../application/components/vis_editor.js | 9 +++- .../public/application/lib/fetch_fields.js | 7 ++- .../vis_type_timeseries/public/plugin.ts | 16 +++++- .../vis_type_timeseries/public/services.ts | 17 +++++- .../server/lib/get_fields.ts | 7 +-- .../abstract_search_strategy.test.js | 48 ++++++++++++++++- .../strategies/abstract_search_strategy.ts | 3 +- .../server/lib/vis_data/get_annotations.js | 3 +- .../server/lib/vis_data/get_series_data.js | 3 +- .../server/lib/vis_data/get_table_data.js | 18 ++++--- .../vis_type_timeseries/server/plugin.ts | 2 + .../server/routes/fields.ts | 9 +++- 26 files changed, 251 insertions(+), 31 deletions(-) create mode 100644 src/plugins/vis_type_timeseries/public/application/components/lib/create_data_source_change_handler.test.ts create mode 100644 src/plugins/vis_type_timeseries/public/application/components/lib/create_data_source_change_handler.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea2dd672f630..c409485dee50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add permission control logic ([#6052](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6052)) - [Multiple Datasource] Add default icon for selectable component and make sure the default datasource shows automatically ([#6327](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6327)) - [Multiple Datasource] Pass selected data sources to plugin consumers when the multi-select component initially loads ([#6333](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6333)) +- [Mulitple Datasource] Add multi data source support to TSVB ([#6298](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6298)) - [Multiple Datasource] Add installedPlugins list to data source saved object ([#6348](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6348)) - [Multiple Datasource] Add default icon in multi-selectable picker ([#6357](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6357)) - [Workspace] Add APIs to support plugin state in request ([#6303](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6303)) diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 46c2b1ca0477..4bc3ad62a4ae 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -145,6 +145,7 @@ export { FieldDescriptor as IndexPatternFieldDescriptor, shouldReadFieldFromDocValues, // used only in logstash_fields fixture FieldDescriptor, + decideClient, } from './index_patterns'; export { diff --git a/src/plugins/data/server/index_patterns/index.ts b/src/plugins/data/server/index_patterns/index.ts index b2e832294e41..771aa9c09ab8 100644 --- a/src/plugins/data/server/index_patterns/index.ts +++ b/src/plugins/data/server/index_patterns/index.ts @@ -31,3 +31,4 @@ export * from './utils'; export { IndexPatternsFetcher, FieldDescriptor, shouldReadFieldFromDocValues } from './fetcher'; export { IndexPatternsService, IndexPatternsServiceStart } from './index_patterns_service'; +export { decideClient } from './routes'; diff --git a/src/plugins/data/server/index_patterns/routes.ts b/src/plugins/data/server/index_patterns/routes.ts index 3adc1970dd81..8b3c7139ffc0 100644 --- a/src/plugins/data/server/index_patterns/routes.ts +++ b/src/plugins/data/server/index_patterns/routes.ts @@ -155,7 +155,7 @@ export function registerRoutes(http: HttpServiceSetup) { ); } -const decideClient = async ( +export const decideClient = async ( context: RequestHandlerContext, request: any ): Promise => { diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx index bfe02b7288a2..8d2a90943b9f 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx @@ -34,6 +34,7 @@ export interface DataSourceSelectorProps { dataSourceFilter?: (dataSource: SavedObject) => boolean; compressed?: boolean; uiSettings?: IUiSettingsClient; + isClearable?: boolean; } interface DataSourceSelectorState { @@ -202,6 +203,7 @@ export class DataSourceSelector extends React.Component< return ( } + helpText={i18n.translate('visTypeTimeseries.indexPattern.searchByIndex', { + defaultMessage: + 'Use an asterisk (*) to match multiple indices. Spaces and the characters , /, ?, ", <, >, | are not allowed.', + })} fullWidth > )} + {!!dataSourceManagementEnabled && ( + + + + + + + + )} , | are not allowed.', + }) } > { + let handleChange: jest.Mock; + let changeHandler: (selectedOptions: []) => void; + + beforeEach(() => { + handleChange = jest.fn(); + changeHandler = createDataSourcePickerHandler(handleChange); + }); + + test.each([ + { + id: undefined, + }, + {}, + ])( + 'calls handleChange() and sets data_source_id to undefined if id cannot be found or is undefined', + ({ id }) => { + // @ts-ignore + changeHandler([{ id }]); + expect(handleChange.mock.calls.length).toEqual(1); + expect(handleChange.mock.calls[0][0]).toEqual({ + data_source_id: undefined, + }); + } + ); + + test.each([ + { + id: '', + }, + { + id: 'foo', + }, + ])('calls handleChange() function with partial and updates the data_source_id', ({ id }) => { + // @ts-ignore + changeHandler([{ id }]); + expect(handleChange.mock.calls.length).toEqual(1); + expect(handleChange.mock.calls[0][0]).toEqual({ + data_source_id: id, + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/create_data_source_change_handler.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/create_data_source_change_handler.ts new file mode 100644 index 000000000000..5fa18d74c5b3 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/create_data_source_change_handler.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import _ from 'lodash'; + +import { PanelSchema } from 'src/plugins/vis_type_timeseries/common/types'; +import { DATA_SOURCE_ID_KEY } from '../../../../common/constants'; + +export const createDataSourcePickerHandler = (handleChange: (e: PanelSchema) => void) => { + return (selectedOptions: []): void => { + return handleChange?.({ + [DATA_SOURCE_ID_KEY]: _.get(selectedOptions, '[0].id', undefined), + } as PanelSchema); + }; +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js index e3d78bd0a824..c91d1f084f91 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js @@ -41,6 +41,7 @@ import { createBrushHandler } from '../lib/create_brush_handler'; import { fetchFields } from '../lib/fetch_fields'; import { extractIndexPatterns } from '../../../../../plugins/vis_type_timeseries/common/extract_index_patterns'; import { getSavedObjectsClient, getUISettings, getDataStart, getCoreStart } from '../../services'; +import { DATA_SOURCE_ID_KEY } from '../../../common/constants'; import { CoreStartContextProvider } from '../contexts/query_input_bar_context'; import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; @@ -113,9 +114,13 @@ export class VisEditor extends Component { } if (this.props.isEditorMode) { + const dataSourceId = nextModel[DATA_SOURCE_ID_KEY] || undefined; const extractedIndexPatterns = extractIndexPatterns(nextModel); - if (!isEqual(this.state.extractedIndexPatterns, extractedIndexPatterns)) { - fetchFields(extractedIndexPatterns).then((visFields) => + if ( + !isEqual(this.state.extractedIndexPatterns, extractedIndexPatterns) || + !isEqual(this.state.model[DATA_SOURCE_ID_KEY], dataSourceId) + ) { + fetchFields(extractedIndexPatterns, dataSourceId).then((visFields) => this.setState({ visFields, extractedIndexPatterns, diff --git a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.js b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.js index cac4c910fee4..8aa9bb618cad 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.js +++ b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.js @@ -30,9 +30,10 @@ import { i18n } from '@osd/i18n'; import { extractIndexPatterns } from '../../../common/extract_index_patterns'; +import { DATA_SOURCE_ID_KEY } from '../../../common/constants'; import { getCoreStart } from '../../services'; -export async function fetchFields(indexPatterns = ['*']) { +export async function fetchFields(indexPatterns = ['*'], dataSourceId = undefined) { const patterns = Array.isArray(indexPatterns) ? indexPatterns : [indexPatterns]; try { const indexFields = await Promise.all( @@ -40,6 +41,7 @@ export async function fetchFields(indexPatterns = ['*']) { return getCoreStart().http.get('/api/metrics/fields', { query: { index: pattern, + data_source: dataSourceId, }, }); }) @@ -62,7 +64,8 @@ export async function fetchFields(indexPatterns = ['*']) { } export async function fetchIndexPatternFields({ params, fields = {} }) { + const dataSourceId = params[DATA_SOURCE_ID_KEY] || undefined; const indexPatterns = extractIndexPatterns(params, fields); - return await fetchFields(indexPatterns); + return await fetchFields(indexPatterns, dataSourceId); } diff --git a/src/plugins/vis_type_timeseries/public/plugin.ts b/src/plugins/vis_type_timeseries/public/plugin.ts index da565a160164..0220a5320422 100644 --- a/src/plugins/vis_type_timeseries/public/plugin.ts +++ b/src/plugins/vis_type_timeseries/public/plugin.ts @@ -36,6 +36,8 @@ import { CoreStart, Plugin, } from 'opensearch-dashboards/public'; +import { DataSourceManagementPluginSetup } from 'src/plugins/data_source_management/public'; +import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; import { VisualizationsSetup } from '../../visualizations/public'; @@ -49,6 +51,8 @@ import { setCoreStart, setDataStart, setChartsSetup, + setDataSourceManagementSetup, + setNotifications, } from './services'; import { DataPublicPluginStart } from '../../data/public'; import { ChartsPluginSetup } from '../../charts/public'; @@ -58,6 +62,8 @@ export interface MetricsPluginSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; charts: ChartsPluginSetup; + dataSourceManagement?: DataSourceManagementPluginSetup; + dataSource?: DataSourcePluginSetup; } /** @internal */ @@ -75,12 +81,19 @@ export class MetricsPlugin implements Plugin, void> { public async setup( core: CoreSetup, - { expressions, visualizations, charts }: MetricsPluginSetupDependencies + { + expressions, + visualizations, + charts, + dataSourceManagement, + dataSource, + }: MetricsPluginSetupDependencies ) { expressions.registerFunction(createMetricsFn); setUISettings(core.uiSettings); setChartsSetup(charts); visualizations.createReactVisualization(metricsVisDefinition); + setDataSourceManagementSetup({ dataSourceManagement }); } public start(core: CoreStart, { data }: MetricsPluginStartDependencies) { @@ -89,5 +102,6 @@ export class MetricsPlugin implements Plugin, void> { setFieldFormats(data.fieldFormats); setDataStart(data); setCoreStart(core); + setNotifications(core.notifications); } } diff --git a/src/plugins/vis_type_timeseries/public/services.ts b/src/plugins/vis_type_timeseries/public/services.ts index 15532bc4fd6f..5f54ac3e7546 100644 --- a/src/plugins/vis_type_timeseries/public/services.ts +++ b/src/plugins/vis_type_timeseries/public/services.ts @@ -28,7 +28,14 @@ * under the License. */ -import { I18nStart, SavedObjectsStart, IUiSettingsClient, CoreStart } from 'src/core/public'; +import { + I18nStart, + SavedObjectsStart, + IUiSettingsClient, + CoreStart, + NotificationsStart, +} from 'src/core/public'; +import { DataSourceManagementPluginSetup } from 'src/plugins/data_source_management/public'; import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; import { ChartsPluginSetup } from '../../charts/public'; import { DataPublicPluginStart } from '../../data/public'; @@ -52,3 +59,11 @@ export const [getI18n, setI18n] = createGetterSetter('I18n'); export const [getChartsSetup, setChartsSetup] = createGetterSetter( 'ChartsPluginSetup' ); + +export const [getDataSourceManagementSetup, setDataSourceManagementSetup] = createGetterSetter<{ + dataSourceManagement: DataSourceManagementPluginSetup | undefined; +}>('DataSourceManagementSetup'); + +export const [getNotifications, setNotifications] = createGetterSetter( + 'Notifications' +); diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts index 1752d3f91f86..56a58b43b45e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts @@ -37,6 +37,7 @@ import { indexPatterns, IndexPatternFieldDescriptor, IndexPatternsFetcher, + decideClient, } from '../../../data/server'; import { ReqFacade } from './search_strategies/strategies/abstract_search_strategy'; @@ -50,15 +51,15 @@ export async function getFields( // removes the need to refactor many layers of dependencies on "req", and instead just augments the top // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. + const client = await decideClient(requestContext, request); + const reqFacade: ReqFacade = { requestContext, ...request, framework, payload: {}, pre: { - indexPatternsService: new IndexPatternsFetcher( - requestContext.core.opensearch.legacy.client.callAsCurrentUser - ), + indexPatternsService: new IndexPatternsFetcher(client), }, getUiSettingsService: () => requestContext.core.uiSettings.client, getSavedObjectsClient: () => requestContext.core.savedObjects.client, diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js index fa130462e78d..068a7ef06c04 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js @@ -65,7 +65,7 @@ describe('AbstractSearchStrategy', () => { }); }); - test('should return response', async () => { + test('should return response for local cluster queries', async () => { const searches = [{ body: 'body', index: 'index' }]; const searchFn = jest.fn().mockReturnValue(Promise.resolve({})); @@ -107,4 +107,50 @@ describe('AbstractSearchStrategy', () => { } ); }); + + test('should return response for datasource query', async () => { + const searches = [{ body: 'body', index: 'index' }]; + const searchFn = jest.fn().mockReturnValue(Promise.resolve({})); + + const responses = await abstractSearchStrategy.search( + { + requestContext: {}, + framework: { + core: { + getStartServices: jest.fn().mockReturnValue( + Promise.resolve([ + {}, + { + data: { + search: { + search: searchFn, + }, + }, + }, + ]) + ), + }, + }, + }, + searches, + {}, + 'some-data-source-id' + ); + + expect(responses).toEqual([{}]); + expect(searchFn).toHaveBeenCalledWith( + {}, + { + dataSourceId: 'some-data-source-id', + params: { + body: 'body', + index: 'index', + }, + indexType: undefined, + }, + { + strategy: 'opensearch', + } + ); + }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 6b69628353ac..7c333fad7f73 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -66,7 +66,7 @@ export class AbstractSearchStrategy { this.additionalParams = additionalParams; } - async search(req: ReqFacade, bodies: any[], options = {}) { + async search(req: ReqFacade, bodies: any[], options = {}, dataSourceId?: string) { const [, deps] = await req.framework.core.getStartServices(); const requests: any[] = []; bodies.forEach((body) => { @@ -74,6 +74,7 @@ export class AbstractSearchStrategy { deps.data.search.search( req.requestContext, { + ...(!!dataSourceId && { dataSourceId }), params: { ...body, ...this.additionalParams, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js index 16b526d1ba2e..3dd88b830346 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js @@ -54,6 +54,7 @@ export async function getAnnotations({ const annotations = panel.annotations.filter(validAnnotation); const lastSeriesTimestamp = getLastSeriesTimestamp(series); const handleAnnotationResponseBy = handleAnnotationResponse(lastSeriesTimestamp); + const panelDataSourceId = panel.data_source_id; const bodiesPromises = annotations.map((annotation) => getAnnotationRequestParams(req, panel, annotation, opensearchQueryConfig, capabilities) @@ -67,7 +68,7 @@ export async function getAnnotations({ if (!searches.length) return { responses: [] }; try { - const data = await searchStrategy.search(req, searches); + const data = await searchStrategy.search(req, searches, {}, panelDataSourceId); return annotations.reduce((acc, annotation, index) => { acc[annotation.id] = handleAnnotationResponseBy(data[index].rawResponse, annotation); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js index a5c8a239d2b0..59d445f93324 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js @@ -41,6 +41,7 @@ export async function getSeriesData(req, panel) { capabilities, } = await req.framework.searchStrategyRegistry.getViableStrategyForPanel(req, panel); const opensearchQueryConfig = await getOpenSearchQueryConfig(req); + const panelDataSourceId = panel.data_source_id; const meta = { type: panel.type, uiRestrictions: capabilities.uiRestrictions, @@ -56,7 +57,7 @@ export async function getSeriesData(req, panel) { [] ); - const data = await searchStrategy.search(req, searches); + const data = await searchStrategy.search(req, searches, {}, panelDataSourceId); const handleResponseBodyFn = handleResponseBody(panel); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js index 0b744638c3d8..20dfa06c1a78 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js @@ -37,6 +37,7 @@ import { getIndexPatternObject } from './helpers/get_index_pattern'; export async function getTableData(req, panel) { const panelIndexPattern = panel.index_pattern; + const panelDataSourceId = panel.data_source_id; const { searchStrategy, @@ -58,12 +59,17 @@ export async function getTableData(req, panel) { indexPatternObject, capabilities ); - const [resp] = await searchStrategy.search(req, [ - { - body, - index: panelIndexPattern, - }, - ]); + const [resp] = await searchStrategy.search( + req, + [ + { + body, + index: panelIndexPattern, + }, + ], + {}, + panelDataSourceId + ); const buckets = get( resp.rawResponse ? resp.rawResponse : resp, diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index 654c85d452b1..af95e2e6b5dc 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -40,6 +40,7 @@ import { } from 'src/core/server'; import { Observable } from 'rxjs'; import { Server } from '@hapi/hapi'; +import { DataSourcePluginSetup } from 'src/plugins/data_source/server'; import { VisTypeTimeseriesConfig } from './config'; import { getVisData, GetVisData, GetVisDataOptions } from './lib/get_vis_data'; import { ValidationTelemetryService } from './validation_telemetry'; @@ -57,6 +58,7 @@ export interface LegacySetup { interface VisTypeTimeseriesPluginSetupDependencies { usageCollection?: UsageCollectionSetup; + dataSource?: DataSourcePluginSetup; } interface VisTypeTimeseriesPluginStartDependencies { diff --git a/src/plugins/vis_type_timeseries/server/routes/fields.ts b/src/plugins/vis_type_timeseries/server/routes/fields.ts index bff34ee159f5..80a04918c517 100644 --- a/src/plugins/vis_type_timeseries/server/routes/fields.ts +++ b/src/plugins/vis_type_timeseries/server/routes/fields.ts @@ -38,12 +38,17 @@ export const fieldsRoutes = (framework: Framework) => { { path: '/api/metrics/fields', validate: { - query: schema.object({ index: schema.string() }), + query: schema.object({ + index: schema.string(), + data_source: schema.maybe(schema.string()), + }), }, }, async (context, req, res) => { try { - return res.ok({ body: await getFields(context, req, framework, req.query.index) }); + return res.ok({ + body: await getFields(context, req, framework, req.query.index), + }); } catch (err) { if (isBoom(err) && err.output.statusCode === 401) { return res.customError({ From 4199162bbdf8245d72f9e9d809504c2055c2d39e Mon Sep 17 00:00:00 2001 From: Samuel Valdes Gutierrez Date: Tue, 16 Apr 2024 15:58:37 -0400 Subject: [PATCH 06/10] [OSCI][FEAT] Changelog Project - PoC Changelog and release notes automation tool - OpenSearch Dashboards (#5519) Refactor and Enhance Workflow Management - Added and updated changesets for multiple PRs to improve tracking and documentation of changes. - Removed unnecessary test and dummy files (`test.txt`, various `.yml` fragments) to clean up the repository. - Refactored workflow scripts to streamline changelog generation and fragment handling, moving temporary files to a designated folder. - Updated GitHub Actions workflows by changing event triggers from `pull_request` to `pull_request_target` and vice versa to optimize workflow execution. - Enhanced security and automation by updating token names and adding write permissions to the changeset workflow. - Deleted obsolete workflow file for creating changeset files, now handled by an automated process. - Major clean-up of dummy fragment files and unnecessary changelog entries to maintain clarity and relevancy in documentation. - Implemented minor updates and improvements in codebase, specifically in generating release notes and handling fragments. --------- Signed-off-by: Johnathon Bowers Signed-off-by: CMDWillYang Signed-off-by: Qiwen Li Signed-off-by: qiwen li Signed-off-by: Samuel Valdes Gutierrez Signed-off-by: Ashwin P Chandran Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Johnathon Bowers Co-authored-by: CMDWillYang Co-authored-by: Qiwen Li Co-authored-by: Ashwin P Chandran Co-authored-by: Anan Zhuang Co-authored-by: Josh Romero Co-authored-by: autochangeset[bot] <154024398+autochangeset[bot]@users.noreply.github.com> Co-authored-by: opensearch-bot[bot] <154024398+opensearch-bot[bot]@users.noreply.github.com> Co-authored-by: opensearch-bot-dev[bot] <154634848+opensearch-bot-dev[bot]@users.noreply.github.com> Co-authored-by: Ashwin P Chandran Co-authored-by: Miki --- .github/pull_request_template.md | 12 ++ .github/workflows/changelog_verifier.yml | 19 --- .../opensearch_changelog_workflow.yml | 23 +++ changelogs/README.md | 5 + package.json | 1 + scripts/generate_release_note.js | 8 ++ src/dev/generate_release_note.ts | 134 ++++++++++++++++++ src/dev/generate_release_note_helper.ts | 59 ++++++++ 8 files changed, 242 insertions(+), 19 deletions(-) delete mode 100644 .github/workflows/changelog_verifier.yml create mode 100644 .github/workflows/opensearch_changelog_workflow.yml create mode 100644 changelogs/README.md create mode 100644 scripts/generate_release_note.js create mode 100644 src/dev/generate_release_note.ts create mode 100644 src/dev/generate_release_note_helper.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3cb6f172b119..662ca15b8d68 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,6 +19,18 @@ the functionality of your change --> +## Changelog + + ### Check List - [ ] All tests pass diff --git a/.github/workflows/changelog_verifier.yml b/.github/workflows/changelog_verifier.yml deleted file mode 100644 index 0890ea8b8fbb..000000000000 --- a/.github/workflows/changelog_verifier.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: "Changelog Verifier" -on: - pull_request: - branches: [ '**', '!feature/**' ] - types: [opened, edited, review_requested, synchronize, reopened, ready_for_review, labeled, unlabeled] - -jobs: - # Enforces the update of a changelog file on every pull request - verify-changelog: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - ref: ${{ github.event.pull_request.head.sha }} - - - uses: dangoslen/changelog-enforcer@v3 - with: - skipLabels: "autocut, Skip-Changelog" diff --git a/.github/workflows/opensearch_changelog_workflow.yml b/.github/workflows/opensearch_changelog_workflow.yml new file mode 100644 index 000000000000..8af8b0d70b0d --- /dev/null +++ b/.github/workflows/opensearch_changelog_workflow.yml @@ -0,0 +1,23 @@ +name: OpenSearch Changelog Workflow + +on: + pull_request_target: + types: [opened, reopened, edited] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + update-changelog: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Parse changelog entries and submit request for changset creation + uses: BigSamu/OpenSearch_Changelog_Workflow@1.0.0-alpha1 + with: + token: ${{secrets.GITHUB_TOKEN}} + CHANGELOG_PR_BRIDGE_URL_DOMAIN: ${{secrets.CHANGELOG_PR_BRIDGE_URL_DOMAIN}} + CHANGELOG_PR_BRIDGE_API_KEY: ${{secrets.CHANGELOG_PR_BRIDGE_API_KEY}} diff --git a/changelogs/README.md b/changelogs/README.md new file mode 100644 index 000000000000..a4620754cfd1 --- /dev/null +++ b/changelogs/README.md @@ -0,0 +1,5 @@ +# Changelog and Release Notes + +For information regarding the changelog and release notes process, please consult the README in the GitHub Actions repository that this process utilizes. To view this README, follow the link below: + +[GitHub Actions Workflow README](https://github.com/BigSamu/OpenSearch_Change_Set_Create_Action/blob/main/README.md) diff --git a/package.json b/package.json index 172c48cfbd70..c728618fcbcd 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "osd:bootstrap": "scripts/use_node scripts/build_ts_refs && scripts/use_node scripts/register_git_hook", "spec_to_console": "scripts/use_node scripts/spec_to_console", "pkg-version": "scripts/use_node -e \"console.log(require('./package.json').version)\"", + "release_note:generate": "scripts/use_node scripts/generate_release_note", "cypress:run-without-security": "env TZ=America/Los_Angeles NO_COLOR=1 cypress run --headless --env SECURITY_ENABLED=false", "cypress:run-with-security": "env TZ=America/Los_Angeles NO_COLOR=1 cypress run --headless --env SECURITY_ENABLED=true,openSearchUrl=https://localhost:9200,WAIT_FOR_LOADER_BUFFER_MS=500", "osd:ciGroup10": "echo \"dashboard_sanity_test_spec.js\"", diff --git a/scripts/generate_release_note.js b/scripts/generate_release_note.js new file mode 100644 index 000000000000..4721fe0dec35 --- /dev/null +++ b/scripts/generate_release_note.js @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +require('../src/setup_node_env'); +require('../src/dev/generate_release_note'); +require('../src/dev/generate_release_note_helper'); diff --git a/src/dev/generate_release_note.ts b/src/dev/generate_release_note.ts new file mode 100644 index 000000000000..4c9eaabf0bf7 --- /dev/null +++ b/src/dev/generate_release_note.ts @@ -0,0 +1,134 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { join, resolve } from 'path'; +import { readFileSync, writeFileSync, Dirent, rm, rename, promises as fsPromises } from 'fs'; +import { load as loadYaml } from 'js-yaml'; +import { readdir } from 'fs/promises'; +import { version as pkgVersion } from '../../package.json'; +import { + validateFragment, + getCurrentDateFormatted, + Changelog, + SECTION_MAPPING, + fragmentDirPath, + SectionKey, + releaseNotesDirPath, + filePath, +} from './generate_release_note_helper'; + +// Function to add content after the 'Unreleased' section in the changelog +function addContentAfterUnreleased(path: string, newContent: string): void { + let fileContent = readFileSync(path, 'utf8'); + const targetString = '## [Unreleased]'; + const targetIndex = fileContent.indexOf(targetString); + + if (targetIndex !== -1) { + const endOfLineIndex = fileContent.indexOf('\n', targetIndex); + if (endOfLineIndex !== -1) { + fileContent = + fileContent.slice(0, endOfLineIndex + 1) + + '\n' + + newContent + + '\n' + + fileContent.slice(endOfLineIndex + 1); + } else { + throw new Error('End of line for "Unreleased" section not found.'); + } + } else { + throw new Error("'## [Unreleased]' not found in the file."); + } + + writeFileSync(path, fileContent); +} + +async function deleteFragments(fragmentTempDirPath: string) { + rm(fragmentTempDirPath, { recursive: true }, (err: any) => { + if (err) { + throw err; + } + }); +} + +// Read fragment files and populate sections +async function readFragments() { + // Initialize sections + const sections: Changelog = (Object.fromEntries( + Object.keys(SECTION_MAPPING).map((key) => [key, []]) + ) as unknown) as Changelog; + + const fragmentPaths = await readdir(fragmentDirPath, { withFileTypes: true }); + for (const fragmentFilename of fragmentPaths) { + // skip non yml or yaml files + if (!/\.ya?ml$/i.test(fragmentFilename.name)) { + // eslint-disable-next-line no-console + console.warn(`Skipping non yml or yaml file ${fragmentFilename.name}`); + continue; + } + + const fragmentPath = join(fragmentDirPath, fragmentFilename.name); + const fragmentContents = readFileSync(fragmentPath, { encoding: 'utf-8' }); + + validateFragment(fragmentContents); + + const fragmentYaml = loadYaml(fragmentContents) as Changelog; + + for (const [sectionKey, entries] of Object.entries(fragmentYaml)) { + sections[sectionKey as SectionKey].push(...entries); + } + } + return { sections, fragmentPaths }; +} + +async function moveFragments(fragmentPaths: Dirent[], fragmentTempDirPath: string): Promise { + // Move fragment files to temp fragments folder + for (const fragmentFilename of fragmentPaths) { + const fragmentPath = resolve(fragmentDirPath, fragmentFilename.name); + const fragmentTempPath = resolve(fragmentTempDirPath, fragmentFilename.name); + rename(fragmentPath, fragmentTempPath, () => {}); + } +} + +function generateChangelog(sections: Changelog) { + // Generate changelog sections + const changelogSections = Object.entries(sections).map(([sectionKey, entries]) => { + const sectionName = SECTION_MAPPING[sectionKey as SectionKey]; + return entries.length === 0 + ? `### ${sectionName}` + : `### ${sectionName}\n\n${entries.map((entry) => ` - ${entry}`).join('\n')}`; + }); + + // Generate full changelog + const currentDate = getCurrentDateFormatted(); + const changelog = `## [${pkgVersion}-${currentDate}](https://github.com/opensearch-project/OpenSearch-Dashboards/releases/tag/${pkgVersion})\n\n${changelogSections.join( + '\n\n' + )}`; + // Update changelog file + addContentAfterUnreleased(filePath, changelog); + return changelogSections; +} + +function generateReleaseNote(changelogSections: string[]) { + // Generate release note + const releaseNoteFilename = `opensearch-dashboards.release-notes-${pkgVersion}.md`; + const releaseNoteHeader = `# VERSION ${pkgVersion} Release Note`; + const releaseNote = `${releaseNoteHeader}\n\n${changelogSections.join('\n\n')}`; + writeFileSync(resolve(releaseNotesDirPath, releaseNoteFilename), releaseNote); +} + +(async () => { + const { sections, fragmentPaths } = await readFragments(); + // create folder for temp fragments + const fragmentTempDirPath = await fsPromises.mkdtemp(join(fragmentDirPath, 'tmp_fragments-')); + // move fragments to temp fragments folder + await moveFragments(fragmentPaths, fragmentTempDirPath); + + const changelogSections = generateChangelog(sections); + + generateReleaseNote(changelogSections); + + // remove temp fragments folder + await deleteFragments(fragmentTempDirPath); +})(); diff --git a/src/dev/generate_release_note_helper.ts b/src/dev/generate_release_note_helper.ts new file mode 100644 index 000000000000..988c0f92d964 --- /dev/null +++ b/src/dev/generate_release_note_helper.ts @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { resolve } from 'path'; + +export const filePath = resolve(__dirname, '..', '..', 'CHANGELOG.md'); +export const fragmentDirPath = resolve(__dirname, '..', '..', 'changelogs', 'fragments'); +export const releaseNotesDirPath = resolve(__dirname, '..', '..', 'release-notes'); + +export function getCurrentDateFormatted(): string { + return new Date().toISOString().slice(0, 10); +} + +export const SECTION_MAPPING = { + breaking: '💥 Breaking Changes', + deprecate: 'Deprecations', + security: '🛡 Security', + feat: '📈 Features/Enhancements', + fix: '🐛 Bug Fixes', + infra: '🚞 Infrastructure', + doc: '📝 Documentation', + chore: '🛠 Maintenance', + refactor: '🪛 Refactoring', + test: '🔩 Tests', +}; + +export type SectionKey = keyof typeof SECTION_MAPPING; +export type Changelog = Record; + +const MAX_ENTRY_LENGTH = 100; +// Each entry must start with '-' and a space, followed by a non-empty string, and be no longer that MAX_ENTRY_LENGTH characters +const entryRegex = new RegExp(`^-.{1,${MAX_ENTRY_LENGTH}}\\(\\[#.+]\\(.+\\)\\)$`); + +// validate format of fragment files +export function validateFragment(content: string) { + const sections = content.split(/(?:\r?\n){2,}/); + + // validate each section + for (const section of sections) { + const lines = section.split('\n'); + const sectionName = lines[0]; + const sectionKey = sectionName.slice(0, -1); + + if (!SECTION_MAPPING[sectionKey as SectionKey] || !sectionName.endsWith(':')) { + throw new Error(`Unknown section ${sectionKey}.`); + } + for (const entry of lines.slice(1)) { + if (entry === '') { + continue; + } + // if (!entryRegex.test(entry)) { + if (!entryRegex.test(entry.trim())) { + throw new Error(`Invalid entry ${entry} in section ${sectionKey}.`); + } + } + } +} From 73e5d78c968f1ab20d28eed6ac6ea772f9c6b620 Mon Sep 17 00:00:00 2001 From: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:59:58 -0700 Subject: [PATCH 07/10] [MDS] Support for Timeline (#6385) * Add MDS support to Timeline Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor to function and add unit tests Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fix typo in args Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor build request to pass unit tests Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add to CHANGELOG Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor error messages + address comments Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fix ut Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Change to data source feature Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fix UT Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Address comments Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --------- Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --- CHANGELOG.md | 1 + .../opensearch_dashboards.json | 1 + .../helpers/timeline_request_handler.ts | 4 +- .../server/handlers/chain_runner.js | 4 +- .../server/lib/fetch_data_source_id.test.ts | 152 ++++++++++++++++++ .../server/lib/fetch_data_source_id.ts | 42 +++++ .../vis_type_timeline/server/lib/services.ts | 10 ++ .../vis_type_timeline/server/plugin.ts | 10 +- .../vis_type_timeline/server/routes/run.ts | 5 +- .../series_functions/opensearch/index.js | 19 ++- .../opensearch/lib/build_request.js | 3 +- src/plugins/vis_type_timeline/server/types.ts | 15 ++ 12 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.test.ts create mode 100644 src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.ts create mode 100644 src/plugins/vis_type_timeline/server/lib/services.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c409485dee50..9cf4d4b0570d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Add default icon in multi-selectable picker ([#6357](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6357)) - [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] Add multi data source support to Timeline ([#6385](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6385)) - [Multiple Datasource] Refactor data source selector component to include placeholder and add tests ([#6372](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6372)) - Replace control characters before logging ([#6402](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6402)) - [Dynamic Configurations] Improve dynamic configurations by adding cache and simplifying client fetch ([#6364](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6364)) diff --git a/src/plugins/vis_type_timeline/opensearch_dashboards.json b/src/plugins/vis_type_timeline/opensearch_dashboards.json index 14b3af415177..c4fa1e8d40fd 100644 --- a/src/plugins/vis_type_timeline/opensearch_dashboards.json +++ b/src/plugins/vis_type_timeline/opensearch_dashboards.json @@ -4,6 +4,7 @@ "opensearchDashboardsVersion": "opensearchDashboards", "server": true, "ui": true, + "optionalPlugins": ["dataSource"], "requiredPlugins": ["visualizations", "data", "expressions"], "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "visDefaultEditor"] } diff --git a/src/plugins/vis_type_timeline/public/helpers/timeline_request_handler.ts b/src/plugins/vis_type_timeline/public/helpers/timeline_request_handler.ts index 467fef727f29..d7b955f96ef9 100644 --- a/src/plugins/vis_type_timeline/public/helpers/timeline_request_handler.ts +++ b/src/plugins/vis_type_timeline/public/helpers/timeline_request_handler.ts @@ -129,10 +129,12 @@ export function getTimelineRequestHandler({ }); } catch (e) { if (e && e.body) { + const errorTitle = + e.body.attributes && e.body.attributes.title ? ` (${e.body.attributes.title})` : ''; const err = new Error( `${i18n.translate('timeline.requestHandlerErrorTitle', { defaultMessage: 'Timeline request error', - })}: ${e.body.title} ${e.body.message}` + })}${errorTitle}: ${e.body.message}` ); err.stack = e.stack; throw err; diff --git a/src/plugins/vis_type_timeline/server/handlers/chain_runner.js b/src/plugins/vis_type_timeline/server/handlers/chain_runner.js index 75382b73de57..39af9939056f 100644 --- a/src/plugins/vis_type_timeline/server/handlers/chain_runner.js +++ b/src/plugins/vis_type_timeline/server/handlers/chain_runner.js @@ -47,7 +47,9 @@ export default function chainRunner(tlConfig) { let sheet; function throwWithCell(cell, exception) { - throw new Error(' in cell #' + (cell + 1) + ': ' + exception.message); + const e = new Error(exception.message); + e.name = `Expression parse error in cell #${cell + 1}`; + throw e; } // Invokes a modifier function, resolving arguments into series as needed diff --git a/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.test.ts b/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.test.ts new file mode 100644 index 000000000000..e5596a001a2d --- /dev/null +++ b/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import { fetchDataSourceIdByName } from './fetch_data_source_id'; +import { OpenSearchFunctionConfig } from '../types'; + +jest.mock('./services', () => ({ + getDataSourceEnabled: jest + .fn() + .mockReturnValueOnce({ enabled: false }) + .mockReturnValue({ enabled: true }), +})); + +describe('fetchDataSourceIdByName()', () => { + const validId = 'some-valid-id'; + const config: OpenSearchFunctionConfig = { + q: null, + metric: null, + split: null, + index: null, + timefield: null, + kibana: null, + opensearchDashboards: null, + interval: null, + }; + const client = savedObjectsClientMock.create(); + client.find = jest.fn().mockImplementation((props) => { + if (props.search === '"No Results With Filter"') { + return Promise.resolve({ + saved_objects: [ + { + id: 'some-non-matching-id', + attributes: { + title: 'No Results With Filter Some Suffix', + }, + }, + ], + }); + } + if (props.search === '"Duplicate Title"') { + return Promise.resolve({ + saved_objects: [ + { + id: 'duplicate-id-1', + attributes: { + title: 'Duplicate Title', + }, + }, + { + id: 'duplicate-id-2', + attributes: { + title: 'Duplicate Title', + }, + }, + ], + }); + } + if (props.search === '"Some Data Source"') { + return Promise.resolve({ + saved_objects: [ + { + id: validId, + attributes: { + title: 'Some Data Source', + }, + }, + ], + }); + } + if (props.search === '"Some Prefix"') { + return Promise.resolve({ + saved_objects: [ + { + id: 'some-id-2', + attributes: { + title: 'Some Prefix B', + }, + }, + { + id: validId, + attributes: { + title: 'Some Prefix', + }, + }, + ], + }); + } + + return Promise.resolve({ saved_objects: [] }); + }); + + it('should return undefined if data_source_name is not present', async () => { + expect(await fetchDataSourceIdByName(config, client)).toBe(undefined); + }); + + it('should return undefined if data_source_name is an empty string', async () => { + expect(await fetchDataSourceIdByName({ ...config, data_source_name: '' }, client)).toBe( + undefined + ); + }); + + it('should throw errors when MDS is disabled', async () => { + await expect( + fetchDataSourceIdByName({ ...config, data_source_name: 'Some Data Source' }, client) + ).rejects.toThrowError( + 'To query from multiple data sources, first enable the data source feature' + ); + }); + + it.each([ + { + dataSourceName: 'Non-existent Data Source', + expectedResultCount: 0, + }, + { + dataSourceName: 'No Results With Filter', + expectedResultCount: 0, + }, + { + dataSourceName: 'Duplicate Title', + expectedResultCount: 2, + }, + ])( + 'should throw errors when non-existent or duplicate data_source_name is provided', + async ({ dataSourceName, expectedResultCount }) => { + await expect( + fetchDataSourceIdByName({ ...config, data_source_name: dataSourceName }, client) + ).rejects.toThrowError( + `Expected exactly 1 result for data_source_name "${dataSourceName}" but got ${expectedResultCount} results` + ); + } + ); + + it.each([ + { + dataSourceName: 'Some Data Source', + }, + { + dataSourceName: 'Some Prefix', + }, + ])( + 'should return valid id when data_source_name exists and is unique', + async ({ dataSourceName }) => { + expect( + await fetchDataSourceIdByName({ ...config, data_source_name: dataSourceName }, client) + ).toBe(validId); + } + ); +}); diff --git a/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.ts b/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.ts new file mode 100644 index 000000000000..e3d0d76d23e7 --- /dev/null +++ b/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'src/core/server'; +import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; +import { getDataSourceEnabled } from './services'; +import { OpenSearchFunctionConfig } from '../types'; + +export const fetchDataSourceIdByName = async ( + config: OpenSearchFunctionConfig, + client: SavedObjectsClientContract +) => { + if (!config.data_source_name) { + return undefined; + } + + if (!getDataSourceEnabled().enabled) { + throw new Error('To query from multiple data sources, first enable the data source feature'); + } + + const dataSources = await client.find({ + type: 'data-source', + perPage: 100, + search: `"${config.data_source_name}"`, + searchFields: ['title'], + fields: ['id', 'title'], + }); + + const possibleDataSourceIds = dataSources.saved_objects.filter( + (obj) => obj.attributes.title === config.data_source_name + ); + + if (possibleDataSourceIds.length !== 1) { + throw new Error( + `Expected exactly 1 result for data_source_name "${config.data_source_name}" but got ${possibleDataSourceIds.length} results` + ); + } + + return possibleDataSourceIds.pop()?.id; +}; diff --git a/src/plugins/vis_type_timeline/server/lib/services.ts b/src/plugins/vis_type_timeline/server/lib/services.ts new file mode 100644 index 000000000000..13b257622abd --- /dev/null +++ b/src/plugins/vis_type_timeline/server/lib/services.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../../opensearch_dashboards_utils/common'; + +export const [getDataSourceEnabled, setDataSourceEnabled] = createGetterSetter<{ + enabled: boolean; +}>('DataSource'); diff --git a/src/plugins/vis_type_timeline/server/plugin.ts b/src/plugins/vis_type_timeline/server/plugin.ts index d2c7097ac419..f768e51e93d0 100644 --- a/src/plugins/vis_type_timeline/server/plugin.ts +++ b/src/plugins/vis_type_timeline/server/plugin.ts @@ -34,6 +34,7 @@ import { TypeOf, schema } from '@osd/config-schema'; import { RecursiveReadonly } from '@osd/utility-types'; import { deepFreeze } from '@osd/std'; +import { DataSourcePluginSetup } from 'src/plugins/data_source/server'; import { PluginStart } from '../../data/server'; import { CoreSetup, PluginInitializerContext } from '../../../core/server'; import { configSchema } from '../config'; @@ -42,11 +43,16 @@ import { functionsRoute } from './routes/functions'; import { validateOpenSearchRoute } from './routes/validate_es'; import { runRoute } from './routes/run'; import { ConfigManager } from './lib/config_manager'; +import { setDataSourceEnabled } from './lib/services'; const experimentalLabel = i18n.translate('timeline.uiSettings.experimentalLabel', { defaultMessage: 'experimental', }); +export interface TimelinePluginSetupDeps { + dataSource?: DataSourcePluginSetup; +} + export interface TimelinePluginStartDeps { data: PluginStart; } @@ -57,7 +63,7 @@ export interface TimelinePluginStartDeps { export class Plugin { constructor(private readonly initializerContext: PluginInitializerContext) {} - public async setup(core: CoreSetup): void { + public async setup(core: CoreSetup, { dataSource }: TimelinePluginSetupDeps): void { const config = await this.initializerContext.config .create>() .pipe(first()) @@ -80,6 +86,8 @@ export class Plugin { ); }; + setDataSourceEnabled({ enabled: !!dataSource }); + const logger = this.initializerContext.logger.get('timeline'); const router = core.http.createRouter(); diff --git a/src/plugins/vis_type_timeline/server/routes/run.ts b/src/plugins/vis_type_timeline/server/routes/run.ts index ab6a993b4bb5..af1005ebcb8f 100644 --- a/src/plugins/vis_type_timeline/server/routes/run.ts +++ b/src/plugins/vis_type_timeline/server/routes/run.ts @@ -122,7 +122,10 @@ export function runRoute( } else { return response.internalError({ body: { - message: err.toString(), + attributes: { + title: err.name, + }, + message: err.message, }, }); } diff --git a/src/plugins/vis_type_timeline/server/series_functions/opensearch/index.js b/src/plugins/vis_type_timeline/server/series_functions/opensearch/index.js index 8837116bfc02..5192059f3e6d 100644 --- a/src/plugins/vis_type_timeline/server/series_functions/opensearch/index.js +++ b/src/plugins/vis_type_timeline/server/series_functions/opensearch/index.js @@ -34,6 +34,7 @@ import { OPENSEARCH_SEARCH_STRATEGY } from '../../../../data/server'; import Datasource from '../../lib/classes/datasource'; import buildRequest from './lib/build_request'; import toSeriesList from './lib/agg_response_to_series_list'; +import { fetchDataSourceIdByName } from '../../lib/fetch_data_source_id'; export default new Datasource('es', { args: [ @@ -112,6 +113,14 @@ export default new Datasource('es', { defaultMessage: `**DO NOT USE THIS**. It's fun for debugging fit functions, but you really should use the interval picker`, }), }, + { + name: 'data_source_name', + types: ['string', 'null'], // If null, the query will proceed with local cluster + help: i18n.translate('timeline.help.functions.opensearch.args.dataSourceNameHelpText', { + defaultMessage: + 'Specify a data source to query from. This will only work if multiple data sources is enabled', + }), + }, ], help: i18n.translate('timeline.help.functions.opensearchHelpText', { defaultMessage: 'Pull data from an opensearch instance', @@ -148,7 +157,15 @@ export default new Datasource('es', { const opensearchShardTimeout = tlConfig.opensearchShardTimeout; - const body = buildRequest(config, tlConfig, scriptedFields, opensearchShardTimeout); + const dataSourceId = await fetchDataSourceIdByName(config, tlConfig.savedObjectsClient); + + const body = buildRequest( + config, + tlConfig, + scriptedFields, + opensearchShardTimeout, + dataSourceId + ); const deps = (await tlConfig.getStartServices())[1]; diff --git a/src/plugins/vis_type_timeline/server/series_functions/opensearch/lib/build_request.js b/src/plugins/vis_type_timeline/server/series_functions/opensearch/lib/build_request.js index 8436b4dbb04a..90fb7b819a08 100644 --- a/src/plugins/vis_type_timeline/server/series_functions/opensearch/lib/build_request.js +++ b/src/plugins/vis_type_timeline/server/series_functions/opensearch/lib/build_request.js @@ -34,7 +34,7 @@ import { buildAggBody } from './agg_body'; import createDateAgg from './create_date_agg'; import { UI_SETTINGS } from '../../../../../data/server'; -export default function buildRequest(config, tlConfig, scriptedFields, timeout) { +export default function buildRequest(config, tlConfig, scriptedFields, timeout, dataSourceId) { const bool = { must: [] }; const timeFilter = { @@ -105,6 +105,7 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) } return { + ...(!!dataSourceId && { dataSourceId }), params: request, }; } diff --git a/src/plugins/vis_type_timeline/server/types.ts b/src/plugins/vis_type_timeline/server/types.ts index f021ffeae00f..2fa3a25c5813 100644 --- a/src/plugins/vis_type_timeline/server/types.ts +++ b/src/plugins/vis_type_timeline/server/types.ts @@ -29,3 +29,18 @@ */ export { TimelineFunctionInterface, TimelineFunctionConfig } from './lib/classes/timeline_function'; + +export interface OpenSearchFunctionConfig { + q: string | null; + metric: string | null; + split: string | null; + index: string | null; + timefield: string | null; + kibana: boolean | null; + opensearchDashboards: boolean | null; + /** + * @deprecated This property should not be set in the Timeline expression. Users should use the interval picker React component instead + */ + interval: string | null; + data_source_name?: string | null; +} From b619ccb5fea8b23a748d74ba3dec21aabb1a7765 Mon Sep 17 00:00:00 2001 From: "Yuanqi(Ella) Zhu" <53279298+zhyuanqi@users.noreply.github.com> Date: Tue, 16 Apr 2024 14:38:25 -0700 Subject: [PATCH 08/10] Modify the button of selectable component to fix the title overflow issue (#6465) * Modify the button of selectable component to fix the title overflow issue Signed-off-by: Yuanqi(Ella) Zhu * update snapshot Signed-off-by: Yuanqi(Ella) Zhu --------- Signed-off-by: Yuanqi(Ella) Zhu --- CHANGELOG.md | 1 + .../public/components/button_title.scss | 7 +++++++ .../create_data_source_menu.test.tsx.snap | 6 +++--- .../__snapshots__/data_source_selectable.test.tsx.snap | 10 +++++----- .../data_source_selectable/data_source_selectable.tsx | 5 +++-- 5 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 src/plugins/data_source_management/public/components/button_title.scss diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf4d4b0570d..f49b43d1e946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [BUG][Multiple Datasource] Refactor read-only component to cover more edge cases ([#6416](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6416)) - [BUG] Fix for checkForFunctionProperty so that order does not matter ([#6248](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6248)) - [Dynamic Configurations] Fix dynamic config API calls to pass correct input ([#6474](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6474)) +- [BUG][Multiple Datasource] Modify the button of selectable component to fix the title overflow issue ([#6465](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6465)) - [BUG][Multiple Datasource] Validation succeed as long as status code in response is 200 ([#6399](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6399)) - [BUG][Multiple Datasource] Add validation for title length to be no longer than 32 characters [#6452](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6452)) diff --git a/src/plugins/data_source_management/public/components/button_title.scss b/src/plugins/data_source_management/public/components/button_title.scss new file mode 100644 index 000000000000..66b32d4ee8b7 --- /dev/null +++ b/src/plugins/data_source_management/public/components/button_title.scss @@ -0,0 +1,7 @@ +.dataSourceComponentButtonTitle { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: auto; + max-width: 16ch; +} diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap index c520768a6890..c705db9194b0 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap @@ -104,7 +104,7 @@ Object { >