diff --git a/CHANGELOG.md b/CHANGELOG.md index 34821326..fdee07f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,4 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Add incontext insight component ([#53](https://github.com/opensearch-project/dashboards-assistant/pull/53)) - Fetch root agent id before executing the agent ([#165](https://github.com/opensearch-project/dashboards-assistant/pull/165)) - Integrate chatbot with sidecar service ([#164](https://github.com/opensearch-project/dashboards-assistant/pull/164)) +- Add data source service ([#191](https://github.com/opensearch-project/dashboards-assistant/pull/191)) diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 9071c643..829de571 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -11,7 +11,9 @@ "opensearchDashboardsUtils" ], "optionalPlugins": [ - "securityDashboards" + "securityDashboards", + "dataSource", + "dataSourceManagement" ], "configPath": [ "assistant" diff --git a/public/contexts/__mocks__/core_context.tsx b/public/contexts/__mocks__/core_context.tsx index bbe746be..5c1dab99 100644 --- a/public/contexts/__mocks__/core_context.tsx +++ b/public/contexts/__mocks__/core_context.tsx @@ -24,6 +24,7 @@ export const useCore = jest.fn(() => { load: jest.fn(), }, conversationLoad: {}, + dataSource: {}, }, }; useCoreMock.services.http.delete.mockReturnValue(Promise.resolve()); diff --git a/public/contexts/core_context.tsx b/public/contexts/core_context.tsx index c2d4ff08..b4143154 100644 --- a/public/contexts/core_context.tsx +++ b/public/contexts/core_context.tsx @@ -8,13 +8,14 @@ import { useOpenSearchDashboards, } from '../../../../src/plugins/opensearch_dashboards_react/public'; import { AssistantPluginStartDependencies, AssistantPluginSetupDependencies } from '../types'; -import { ConversationLoadService, ConversationsService } from '../services'; +import { ConversationLoadService, ConversationsService, DataSourceService } from '../services'; export interface AssistantServices extends Required { setupDeps: AssistantPluginSetupDependencies; startDeps: AssistantPluginStartDependencies; conversationLoad: ConversationLoadService; conversations: ConversationsService; + dataSource: DataSourceService; } export const useCore: () => OpenSearchDashboardsReactContextValue< diff --git a/public/plugin.tsx b/public/plugin.tsx index 2d15682f..76912891 100644 --- a/public/plugin.tsx +++ b/public/plugin.tsx @@ -31,6 +31,7 @@ import { setIncontextInsightRegistry, } from './services'; import { ConfigSchema } from '../common/types/config'; +import { DataSourceService } from './services/data_source_service'; export const [getCoreStart, setCoreStart] = createGetterSetter('CoreStart'); @@ -58,9 +59,11 @@ export class AssistantPlugin > { private config: ConfigSchema; incontextInsightRegistry: IncontextInsightRegistry | undefined; + private dataSourceService: DataSourceService; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); + this.dataSourceService = new DataSourceService(); } public setup( @@ -95,6 +98,11 @@ export class AssistantPlugin const checkAccess = (account: Awaited>) => account.data.roles.some((role) => ['all_access', 'assistant_user'].includes(role)); + const dataSourceSetupResult = this.dataSourceService.setup({ + uiSettings: core.uiSettings, + dataSourceManagement: setupDeps.dataSourceManagement, + }); + if (this.config.chat.enabled) { const setupChat = async () => { const [coreStart, startDeps] = await core.getStartServices(); @@ -105,6 +113,7 @@ export class AssistantPlugin startDeps, conversationLoad: new ConversationLoadService(coreStart.http), conversations: new ConversationsService(coreStart.http), + dataSource: this.dataSourceService, }); const account = await getAccount(); const username = account.data.user_name; @@ -131,6 +140,7 @@ export class AssistantPlugin } return { + dataSource: dataSourceSetupResult, registerMessageRenderer: (contentType, render) => { if (contentType in messageRenderers) console.warn(`Content renderer type ${contentType} is already registered.`); @@ -160,8 +170,12 @@ export class AssistantPlugin setChrome(core.chrome); setNotifications(core.notifications); - return {}; + return { + dataSource: this.dataSourceService.start(), + }; } - public stop() {} + public stop() { + this.dataSourceService.stop(); + } } diff --git a/public/services/__tests__/data_source_service.test.ts b/public/services/__tests__/data_source_service.test.ts new file mode 100644 index 00000000..380dbae2 --- /dev/null +++ b/public/services/__tests__/data_source_service.test.ts @@ -0,0 +1,212 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { first } from 'rxjs/operators'; + +import { uiSettingsServiceMock } from '../../../../../src/core/public/mocks'; +import { DataSourceOption } from '../../../../../src/plugins/data_source_management/public/components/data_source_menu/types'; +import { DataSourceManagementPluginSetup } from '../../types'; +import { DataSourceService } from '../data_source_service'; + +const setup = (options?: { + dataSourceManagement?: DataSourceManagementPluginSetup; + defaultDataSourceId?: string | null; + dataSourceSelection?: Map; +}) => { + const dataSourceSelection$ = new BehaviorSubject>( + options?.dataSourceSelection ?? new Map() + ); + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const dataSourceManagement: DataSourceManagementPluginSetup = { + dataSourceSelection: { + getSelection$: () => dataSourceSelection$, + }, + }; + const dataSource = new DataSourceService(); + const defaultDataSourceSelection$ = new BehaviorSubject(options?.defaultDataSourceId ?? null); + uiSettings.get$.mockReturnValue(defaultDataSourceSelection$); + const setupResult = dataSource.setup({ + uiSettings, + dataSourceManagement: + options && 'dataSourceManagement' in options + ? options.dataSourceManagement + : dataSourceManagement, + }); + + return { + dataSource, + dataSourceSelection$, + defaultDataSourceSelection$, + setupResult, + }; +}; + +describe('DataSourceService', () => { + describe('getDataSourceId$', () => { + it('should return data source selection provided value', async () => { + const { dataSource } = setup({ + defaultDataSourceId: 'foo', + dataSourceSelection: new Map([['test', [{ label: 'Bar', id: 'bar' }]]]), + }); + + expect(await dataSource.getDataSourceId$().pipe(first()).toPromise()).toBe('bar'); + }); + it('should return data source selection provided value even default data source changed', async () => { + const { dataSource, defaultDataSourceSelection$ } = setup({ + defaultDataSourceId: 'foo', + dataSourceSelection: new Map([['test', [{ label: 'Bar', id: 'bar' }]]]), + }); + + defaultDataSourceSelection$.next('baz'); + expect(await dataSource.getDataSourceId$().pipe(first()).toPromise()).toBe('bar'); + }); + it('should return default data source id if no data source selection', async () => { + const { dataSource } = setup({ defaultDataSourceId: 'foo' }); + + expect(await dataSource.getDataSourceId$().pipe(first()).toPromise()).toBe('foo'); + }); + it('should return default data source id if data source selection become empty', () => { + const { dataSource, dataSourceSelection$ } = setup({ + defaultDataSourceId: 'foo', + dataSourceSelection: new Map([['test', [{ label: 'Bar', id: 'bar' }]]]), + }); + const observerFn = jest.fn(); + dataSource.getDataSourceId$().subscribe(observerFn); + expect(observerFn).toHaveBeenLastCalledWith('bar'); + + dataSourceSelection$.next(new Map()); + expect(observerFn).toHaveBeenLastCalledWith('foo'); + }); + it('should return default data source for multi data source selection', async () => { + const { dataSource, dataSourceSelection$ } = setup({ + defaultDataSourceId: 'baz', + dataSourceSelection: new Map([ + [ + 'test', + [ + { label: 'Foo', id: 'foo' }, + { label: 'Bar', id: 'bar' }, + ], + ], + ]), + }); + + expect(await dataSource.getDataSourceId$().pipe(first()).toPromise()).toBe('baz'); + + dataSourceSelection$.next( + new Map([ + ['component1', [{ label: 'Foo', id: 'foo' }]], + ['component2', [{ label: 'Bar', id: 'bar' }]], + ]) + ); + expect(await dataSource.getDataSourceId$().pipe(first()).toPromise()).toBe('baz'); + }); + it('should return default data source for empty data source selection', async () => { + const { dataSource } = setup({ + defaultDataSourceId: 'foo', + dataSourceSelection: new Map(), + }); + expect(await dataSource.getDataSourceId$().pipe(first()).toPromise()).toBe('foo'); + }); + }); + + describe('isMDSEnabled', () => { + it('should return true if multi data source provided', () => { + const { dataSource } = setup(); + expect(dataSource.isMDSEnabled()).toBe(true); + }); + it('should return false if multi data source not provided', () => { + const { dataSource } = setup({ dataSourceManagement: undefined }); + expect(dataSource.isMDSEnabled()).toBe(false); + }); + }); + describe('getDataSourceQuery', () => { + it('should return empty object if MDS not enabled', async () => { + const { dataSource } = setup({ dataSourceManagement: undefined }); + expect(await dataSource.getDataSourceQuery()).toEqual({}); + }); + it('should return empty object if data source id is empty', async () => { + const { dataSource } = setup({ + dataSourceSelection: new Map([['test', [{ label: '', id: '' }]]]), + }); + expect(await dataSource.getDataSourceQuery()).toEqual({}); + }); + it('should return query object with provided data source id', async () => { + const { dataSource } = setup({ defaultDataSourceId: 'foo' }); + expect(await dataSource.getDataSourceQuery()).toEqual({ dataSourceId: 'foo' }); + }); + it('should throw error if data source id not exists', async () => { + const { dataSource } = setup(); + let error; + try { + await dataSource.getDataSourceQuery(); + } catch (e) { + error = e; + } + expect(error).toBeTruthy(); + }); + }); + describe('stop', () => { + it('should not emit after data source selection unsubscribe', async () => { + const { dataSource, dataSourceSelection$ } = setup(); + const observerFn = jest.fn(); + dataSource.getDataSourceId$().subscribe(observerFn); + expect(observerFn).toHaveBeenCalledTimes(1); + dataSource.stop(); + dataSourceSelection$.next(new Map([['test', [{ label: 'Foo', id: 'foo' }]]])); + expect(observerFn).toHaveBeenCalledTimes(1); + }); + it('should not emit after data source id subject complete', () => { + const { dataSource } = setup(); + const observerFn = jest.fn(); + dataSource.getDataSourceId$().subscribe(observerFn); + expect(observerFn).toHaveBeenCalledTimes(1); + dataSource.stop(); + dataSource.setDataSourceId('foo'); + expect(observerFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('setup', () => { + it('should able to change data source id from setup result', async () => { + const { dataSource, setupResult } = setup(); + setupResult.setDataSourceId('foo'); + expect(await dataSource.getDataSourceId$().pipe(first()).toPromise()).toBe('foo'); + }); + + it('should update data source id after data source selection changed', () => { + const { dataSource, dataSourceSelection$ } = setup(); + const observerFn = jest.fn(); + dataSource.getDataSourceId$().subscribe(observerFn); + + dataSourceSelection$.next(new Map([['test', [{ label: 'Foo', id: 'foo' }]]])); + expect(observerFn).toHaveBeenLastCalledWith('foo'); + + dataSourceSelection$.next(new Map([['test', [{ label: 'Bar', id: 'bar' }]]])); + expect(observerFn).toHaveBeenLastCalledWith('bar'); + }); + }); + + it('should able to change data source id from start result', async () => { + const { dataSource } = setup(); + dataSource.start().setDataSourceId('bar'); + expect(await dataSource.getDataSourceId$().pipe(first()).toPromise()).toBe('bar'); + }); + + it('should not fire change when call setDataSourceId with same data source id', async () => { + const { dataSource } = setup(); + const observerFn = jest.fn(); + dataSource.getDataSourceId$().subscribe(observerFn); + dataSource.setDataSourceId('foo'); + expect(observerFn).toHaveBeenCalledTimes(2); + + dataSource.setDataSourceId('foo'); + expect(observerFn).toHaveBeenCalledTimes(2); + + dataSource.setDataSourceId('bar'); + expect(observerFn).toHaveBeenCalledTimes(3); + }); +}); diff --git a/public/services/data_source_service.ts b/public/services/data_source_service.ts new file mode 100644 index 00000000..8ddb8fad --- /dev/null +++ b/public/services/data_source_service.ts @@ -0,0 +1,127 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject, Subscription, combineLatest, of } from 'rxjs'; +import { first, map } from 'rxjs/operators'; + +import type { IUiSettingsClient } from '../../../../src/core/public'; +import type { DataSourceOption } from '../../../../src/plugins/data_source_management/public/components/data_source_menu/types'; +import type { DataSourceManagementPluginSetup } from '../types'; + +export enum DataSourceIdFrom { + UiSettings, + DataSourceSelection, + Customized, +} + +export interface DataSourceServiceContract { + setDataSourceId: (newDataSourceId: string | null) => void; +} + +const getSingleSelectedDataSourceOption = ( + dataSourceSelection: Map +) => { + const values = [...dataSourceSelection.values()]; + // Should use default index if multi data source selected + if (values.length === 0 || values.length > 1 || values?.[0]?.length > 1) { + return null; + } + return values[0][0]; +}; + +export class DataSourceService { + private dataSourceId$ = new BehaviorSubject(null); + private uiSettings: IUiSettingsClient | undefined; + private dataSourceManagement: DataSourceManagementPluginSetup | undefined; + private dataSourceSelectionSubscription: Subscription | undefined; + + constructor() {} + + private init() { + if (!this.isMDSEnabled()) { + return; + } + if (!this.dataSourceManagement?.dataSourceSelection) { + return; + } + this.dataSourceSelectionSubscription = this.dataSourceManagement.dataSourceSelection + .getSelection$() + .pipe(map(getSingleSelectedDataSourceOption)) + .subscribe((v) => { + this.setDataSourceId(v?.id ?? null); + }); + } + + async getDataSourceQuery() { + if (!this.isMDSEnabled()) { + return {}; + } + const dataSourceId = await this.getDataSourceId$().pipe(first()).toPromise(); + if (dataSourceId === null) { + throw new Error('No data source id'); + } + // empty means using local cluster + if (dataSourceId === '') { + return {}; + } + return { dataSourceId }; + } + + isMDSEnabled() { + return !!this.dataSourceManagement; + } + + setDataSourceId(newDataSourceId: string | null) { + if (this.dataSourceId$.getValue() === newDataSourceId) { + return; + } + this.dataSourceId$.next(newDataSourceId); + } + + getDataSourceId$() { + return combineLatest([ + this.dataSourceId$, + this.uiSettings?.get$('defaultDataSource', null) ?? of(null), + ]).pipe( + map(([selectedDataSourceId, defaultDataSourceId]) => { + if (selectedDataSourceId !== null) { + return selectedDataSourceId; + } + return defaultDataSourceId; + }) + ); + } + + setup({ + uiSettings, + dataSourceManagement, + }: { + uiSettings: IUiSettingsClient; + dataSourceManagement?: DataSourceManagementPluginSetup; + }): DataSourceServiceContract { + this.uiSettings = uiSettings; + this.dataSourceManagement = dataSourceManagement; + this.init(); + + return { + setDataSourceId: (newDataSourceId: string | null) => { + this.setDataSourceId(newDataSourceId); + }, + }; + } + + start(): DataSourceServiceContract { + return { + setDataSourceId: (newDataSourceId: string | null) => { + this.setDataSourceId(newDataSourceId); + }, + }; + } + + public stop() { + this.dataSourceSelectionSubscription?.unsubscribe(); + this.dataSourceId$.complete(); + } +} diff --git a/public/services/index.ts b/public/services/index.ts index 72243960..64619f6d 100644 --- a/public/services/index.ts +++ b/public/services/index.ts @@ -20,3 +20,5 @@ export const [getChrome, setChrome] = createGetterSetter('Chrome'); export const [getNotifications, setNotifications] = createGetterSetter( 'Notifications' ); + +export { DataSourceService, DataSourceServiceContract } from './data_source_service'; diff --git a/public/types.ts b/public/types.ts index f4bde66f..02101e14 100644 --- a/public/types.ts +++ b/public/types.ts @@ -3,12 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { BehaviorSubject } from 'rxjs'; import { DashboardStart } from '../../../src/plugins/dashboard/public'; import { EmbeddableSetup, EmbeddableStart } from '../../../src/plugins/embeddable/public'; import { IMessage, ISuggestedAction } from '../common/types/chat_saved_object_attributes'; import { IChatContext } from './contexts/chat_context'; import { MessageContentProps } from './tabs/chat/messages/message_content'; -import { IncontextInsightRegistry } from './services'; +import { DataSourceServiceContract, IncontextInsightRegistry } from './services'; +import { DataSourceOption } from '../../../src/plugins/data_source_management/public'; + +// TODO: should replace from DataSourceManagementPluginSetup in DSM plugin after data selection merged +export interface DataSourceManagementPluginSetup { + dataSourceSelection?: { + getSelection$: () => BehaviorSubject>; + }; +} export interface RenderProps { props: MessageContentProps; @@ -34,9 +43,11 @@ export interface AssistantPluginStartDependencies { export interface AssistantPluginSetupDependencies { embeddable: EmbeddableSetup; securityDashboards?: {}; + dataSourceManagement?: DataSourceManagementPluginSetup; } export interface AssistantSetup { + dataSource: DataSourceServiceContract; registerMessageRenderer: (contentType: string, render: MessageRenderer) => void; registerActionExecutor: (actionType: string, execute: ActionExecutor) => void; /** @@ -52,8 +63,9 @@ export interface AssistantSetup { renderIncontextInsight: (component: React.ReactNode) => React.ReactNode; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface AssistantStart {} +export interface AssistantStart { + dataSource: DataSourceServiceContract; +} export interface UserAccount { username: string;