diff --git a/changelogs/fragments/9745.yml b/changelogs/fragments/9745.yml new file mode 100644 index 000000000000..85c5c074fd5f --- /dev/null +++ b/changelogs/fragments/9745.yml @@ -0,0 +1,2 @@ +feat: +- [Integration] Vended Dashboards Synchronization #9745 ([#9745](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/9745)) \ No newline at end of file diff --git a/src/plugins/dashboard/config.ts b/src/plugins/dashboard/config.ts index 14125fe69333..46fc45c7ea56 100644 --- a/src/plugins/dashboard/config.ts +++ b/src/plugins/dashboard/config.ts @@ -32,6 +32,7 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ allowByValueEmbeddables: schema.boolean({ defaultValue: false }), + directQueryConnectionSync: schema.boolean({ defaultValue: false }), }); export type ConfigSchema = TypeOf; diff --git a/src/plugins/dashboard/opensearch_dashboards.json b/src/plugins/dashboard/opensearch_dashboards.json index 348a0c9fe9dc..fc68b4c3ca2b 100644 --- a/src/plugins/dashboard/opensearch_dashboards.json +++ b/src/plugins/dashboard/opensearch_dashboards.json @@ -14,5 +14,5 @@ "optionalPlugins": ["home", "share", "usageCollection"], "server": true, "ui": true, - "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "home"] + "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "home", "dataSourceManagement"] } diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 6f6c59afe8d5..76b01f5bf83b 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -921,6 +921,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` }, "savedObjectsClient": Object { "find": [MockFunction], + "get": [MockFunction], }, "savedObjectsPublic": Object { "settings": Object { @@ -2048,6 +2049,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = }, "savedObjectsClient": Object { "find": [MockFunction], + "get": [MockFunction], }, "savedObjectsPublic": Object { "settings": Object { @@ -3175,6 +3177,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b }, "savedObjectsClient": Object { "find": [MockFunction], + "get": [MockFunction], }, "savedObjectsPublic": Object { "settings": Object { @@ -4302,6 +4305,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu }, "savedObjectsClient": Object { "find": [MockFunction], + "get": [MockFunction], }, "savedObjectsPublic": Object { "settings": Object { @@ -5429,6 +5433,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be }, "savedObjectsClient": Object { "find": [MockFunction], + "get": [MockFunction], }, "savedObjectsPublic": Object { "settings": Object { @@ -6556,6 +6561,7 @@ exports[`Dashboard top nav render with all components 1`] = ` }, "savedObjectsClient": Object { "find": [MockFunction], + "get": [MockFunction], }, "savedObjectsPublic": Object { "settings": Object { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index d994e98142db..5aa579b58437 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -59,8 +59,10 @@ import { OpenSearchDashboardsReactContext, OpenSearchDashboardsReactContextValue, } from '../../../../opensearch_dashboards_react/public'; +import { DirectQuerySyncProvider } from './direct_query_sync/direct_query_sync_context'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; +import { DashboardFeatureFlagConfig } from '../../plugin'; export interface DashboardContainerInput extends ContainerInput { viewMode: ViewMode; @@ -104,6 +106,9 @@ export interface DashboardContainerOptions { SavedObjectFinder: React.ComponentType; ExitFullScreenButton: React.ComponentType; uiActions: UiActionsStart; + savedObjectsClient: CoreStart['savedObjects']['client']; + http: CoreStart['http']; + dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; } export type DashboardReactContextValue = OpenSearchDashboardsReactContextValue< @@ -121,6 +126,9 @@ export class DashboardContainer extends Container @@ -190,10 +210,6 @@ export class DashboardContainer extends Container, newPanelState: Partial ) { - // TODO: In the current infrastructure, embeddables in a container do not react properly to - // changes. Removing the existing embeddable, and adding a new one is a temporary workaround - // until the container logic is fixed. - const finalPanels = { ...this.input.panels }; delete finalPanels[previousPanelState.explicitInput.id]; const newPanelId = newPanelState.explicitInput?.id ? newPanelState.explicitInput.id : uuid.v4(); @@ -235,20 +251,48 @@ export class DashboardContainer extends Container - - - - , - dom + this.renderCount++; + console.log( + `[DashboardContainer] Rendering (instanceId: ${this.instanceId}, count: ${this.renderCount}, hasRendered: ${this.hasRendered})` ); + + const DashboardContainerWrapper = () => { + return ( + + + + + + + + ); + }; + + if (!this.hasRendered) { + console.log( + `[DashboardContainer] Initial render with ReactDOM.render (instanceId: ${this.instanceId})` + ); + ReactDOM.render(, dom); + this.hasRendered = true; + } else { + console.log( + `[DashboardContainer] Skipping ReactDOM.render, already rendered (instanceId: ${this.instanceId})` + ); + } } protected getInheritedInput(id: string): InheritedChildInput { diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/_dashboard_direct_query_sync.scss b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/_dashboard_direct_query_sync.scss new file mode 100644 index 000000000000..91997b284eb5 --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/_dashboard_direct_query_sync.scss @@ -0,0 +1,4 @@ +.dshDashboardGrid__syncBar { + margin-bottom: $euiSizeS; + margin-left: $euiSizeS; +} diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/dashboard_direct_query_sync.test.tsx b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/dashboard_direct_query_sync.test.tsx new file mode 100644 index 000000000000..2f8f64e53681 --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/dashboard_direct_query_sync.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { DashboardDirectQuerySync } from './dashboard_direct_query_sync'; + +describe('DashboardDirectQuerySync', () => { + const mockSynchronize = jest.fn(); + + it('renders sync info with refresh link when state is terminal', () => { + render( + + ); + + expect(screen.getByText(/Data scheduled to sync every/i)).toBeInTheDocument(); + expect(screen.getByText(/Sync data/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByText(/Sync data/i)); + expect(mockSynchronize).toHaveBeenCalled(); + }); + + it('renders loading spinner when state is not terminal', () => { + render( + + ); + + expect(screen.getByText(/Data sync is in progress/i)).toBeInTheDocument(); + expect(screen.queryByText(/Sync data/i)).toBeNull(); + }); + + it('handles missing lastRefreshTime and refreshInterval gracefully', () => { + render( + + ); + + expect( + screen.getByText( + (content) => content.includes('Data scheduled to sync every') && content.includes('--') + ) + ).toBeInTheDocument(); + + expect(screen.getByText(/Sync data/i)).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/dashboard_direct_query_sync.tsx b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/dashboard_direct_query_sync.tsx new file mode 100644 index 000000000000..763be1329301 --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/dashboard_direct_query_sync.tsx @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiText } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { DirectQueryLoadingStatus } from '../../../../../data_source_management/public'; +import { EMR_STATES, intervalAsMinutes } from './direct_query_sync'; +import './_dashboard_direct_query_sync.scss'; + +export interface DashboardDirectQuerySyncProps { + loadStatus?: DirectQueryLoadingStatus; + lastRefreshTime?: number; + refreshInterval?: number; + onSynchronize: () => void; + className?: string; +} + +export const DashboardDirectQuerySync: React.FC = ({ + loadStatus, + lastRefreshTime, + refreshInterval, + onSynchronize, + className, +}) => { + console.log('DashboardDirectQuerySync: Rendering with props:', { + loadStatus, + lastRefreshTime, + refreshInterval, + className, + onSynchronize: '[Function]', // Simplified since onSynchronize is guaranteed to be a function + }); + + // If loadStatus is undefined, default to a non-terminal state to avoid errors + const state = loadStatus ? EMR_STATES.get(loadStatus)! : { ord: 0, terminal: false }; + console.log('DashboardDirectQuerySync: Computed state=', state); + + return ( +
+ {state.terminal ? ( + <> + {console.log('DashboardDirectQuerySync: Rendering terminal state UI')} + + {i18n.translate('dashboard.directQuerySync.dataScheduledToSync', { + defaultMessage: 'Data scheduled to sync every {interval}. Last sync: {lastSyncTime}.', + values: { + interval: refreshInterval ? intervalAsMinutes(1000 * refreshInterval) : '--', + lastSyncTime: lastRefreshTime + ? `${new Date(lastRefreshTime).toLocaleString()} (${intervalAsMinutes( + new Date().getTime() - lastRefreshTime + )} ago)` + : '--', + }, + })} + + + {i18n.translate('dashboard.directQuerySync.syncDataLink', { + defaultMessage: 'Sync data', + })} + + + + ) : ( + <> + {console.log('DashboardDirectQuerySync: Rendering in-progress state UI')} + + + + {i18n.translate('dashboard.directQuerySync.dataSyncInProgress', { + defaultMessage: + 'Data sync is in progress ({progress}% complete). The dashboard will reload on completion.', + values: { + progress: state.ord, + }, + })} + + + )} +
+ ); +}; diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync.test.ts b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync.test.ts new file mode 100644 index 000000000000..b39b3ceba9ca --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync.test.ts @@ -0,0 +1,302 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + sourceCheck, + resolveConcreteIndex, + extractIndexParts, + generateRefreshQuery, + fetchIndexMapping, + extractIndexInfoFromDashboard, +} from './direct_query_sync'; +import { HttpStart, SavedObjectsClientContract } from 'opensearch-dashboards/public'; + +describe('sourceCheck', () => { + it('returns true if all indexPatternIds and mdsIds are the same', () => { + const indexPatternIds = ['pattern-1', 'pattern-1', 'pattern-1']; + const mdsIds = ['mds-1', 'mds-1', 'mds-1']; + + expect(sourceCheck(indexPatternIds, mdsIds)).toBe(true); + }); + + it('returns false if indexPatternIds are different', () => { + const indexPatternIds = ['pattern-1', 'pattern-2', 'pattern-1']; + const mdsIds = ['mds-1', 'mds-1', 'mds-1']; + + expect(sourceCheck(indexPatternIds, mdsIds)).toBe(false); + }); + + it('returns false if mdsIds are different', () => { + const indexPatternIds = ['pattern-1', 'pattern-1', 'pattern-1']; + const mdsIds = ['mds-1', 'mds-2', 'mds-1']; + + expect(sourceCheck(indexPatternIds, mdsIds)).toBe(false); + }); + + it('returns false if both indexPatternIds and mdsIds are different', () => { + const indexPatternIds = ['pattern-1', 'pattern-2']; + const mdsIds = ['mds-1', 'mds-2']; + + expect(sourceCheck(indexPatternIds, mdsIds)).toBe(false); + }); + + it('returns true if empty arrays (edge case)', () => { + expect(sourceCheck([], [])).toBe(true); + }); + + it('returns true if single entry arrays', () => { + expect(sourceCheck(['pattern-1'], ['mds-1'])).toBe(true); + }); + + it('returns false if mdsIds contains undefined and other values', () => { + const indexPatternIds = ['pattern-1', 'pattern-1']; + const mdsIds = [undefined, 'mds-1']; + expect(sourceCheck(indexPatternIds, mdsIds)).toBe(false); + }); + + it('returns true if all mdsIds are undefined', () => { + const indexPatternIds = ['pattern-1', 'pattern-1']; + const mdsIds = [undefined, undefined]; + expect(sourceCheck(indexPatternIds, mdsIds)).toBe(true); + }); +}); + +describe('resolveConcreteIndex', () => { + let mockHttp: jest.Mocked; + + beforeEach(() => { + mockHttp = ({ + get: jest.fn(), + } as unknown) as jest.Mocked; + }); + + it('returns the input index if it does not contain wildcards', async () => { + const result = await resolveConcreteIndex('my_index', mockHttp); + expect(result).toBe('my_index'); + expect(mockHttp.get).not.toHaveBeenCalled(); + }); + + it('resolves wildcard index with mdsId', async () => { + mockHttp.get.mockResolvedValue({ indices: [{ name: 'resolved_index' }] }); + const result = await resolveConcreteIndex('my_index*', mockHttp, 'mds-1'); + expect(mockHttp.get).toHaveBeenCalledWith( + '/internal/index-pattern-management/resolve_index/my_index*', + { + query: { data_source: 'mds-1' }, + } + ); + expect(result).toBe('resolved_index'); + }); + + it('returns null if no matching indices are found', async () => { + mockHttp.get.mockResolvedValue({ indices: [] }); + const result = await resolveConcreteIndex('my_index*', mockHttp); + expect(result).toBe(null); + }); + + it('returns null on HTTP error', async () => { + mockHttp.get.mockRejectedValue(new Error('Network error')); + const result = await resolveConcreteIndex('my_index*', mockHttp); + expect(result).toBe(null); + }); +}); + +describe('extractIndexParts', () => { + it('correctly extracts parts from a mapping name', () => { + const result = extractIndexParts('datasource1.database1.my_index'); + expect(result).toEqual({ + datasource: 'datasource1', + database: 'database1', + index: 'my_index', + }); + }); + + it('handles missing parts with null values', () => { + const result = extractIndexParts('datasource1'); + expect(result).toEqual({ + datasource: 'datasource1', + database: null, + index: null, + }); + }); + + it('handles empty mapping name with null values', () => { + const result = extractIndexParts(''); + expect(result).toEqual({ + datasource: null, + database: null, + index: null, + }); + }); + + it('returns null values when mappingName is undefined', () => { + const result = extractIndexParts(undefined); + expect(result).toEqual({ + datasource: null, + database: null, + index: null, + }); + }); +}); + +describe('generateRefreshQuery', () => { + it('generates correct refresh query', () => { + const info = { + datasource: 'datasource1', + database: 'database1', + index: 'my_index', + }; + const result = generateRefreshQuery(info); + expect(result).toBe('REFRESH MATERIALIZED VIEW `datasource1`.`database1`.`my_index`'); + }); + + it('throws an error if datasource is null', () => { + const info = { + datasource: null, + database: 'database1', + index: 'my_index', + }; + expect(() => generateRefreshQuery(info)).toThrow( + 'Cannot generate refresh query: missing required datasource, database, or index' + ); + }); + + it('throws an error if database is null', () => { + const info = { + datasource: 'datasource1', + database: null, + index: 'my_index', + }; + expect(() => generateRefreshQuery(info)).toThrow( + 'Cannot generate refresh query: missing required datasource, database, or index' + ); + }); + + it('throws an error if index is null', () => { + const info = { + datasource: 'datasource1', + database: 'database1', + index: null, + }; + expect(() => generateRefreshQuery(info)).toThrow( + 'Cannot generate refresh query: missing required datasource, database, or index' + ); + }); +}); + +describe('fetchIndexMapping', () => { + let mockHttp: jest.Mocked; + + beforeEach(() => { + mockHttp = ({ + get: jest.fn(), + } as unknown) as jest.Mocked; + }); + + it('fetches mapping without mdsId', async () => { + const mockResponse = { mapping: { _meta: { properties: { lastRefreshTime: 12345 } } } }; + mockHttp.get.mockResolvedValue(mockResponse); + const result = await fetchIndexMapping('my_index', mockHttp); + expect(mockHttp.get).toHaveBeenCalledWith('/api/directquery/dsl/indices.getFieldMapping', { + query: { index: 'my_index' }, + }); + expect(result).toBe(mockResponse); + }); + + it('fetches mapping with mdsId', async () => { + const mockResponse = { mapping: { _meta: { properties: { lastRefreshTime: 12345 } } } }; + mockHttp.get.mockResolvedValue(mockResponse); + const result = await fetchIndexMapping('my_index', mockHttp, 'mds-1'); + expect(mockHttp.get).toHaveBeenCalledWith( + '/api/directquery/dsl/indices.getFieldMapping/dataSourceMDSId=mds-1', + { + query: { index: 'my_index' }, + } + ); + expect(result).toBe(mockResponse); + }); + + it('returns null on error', async () => { + mockHttp.get.mockRejectedValue(new Error('Network error')); + const result = await fetchIndexMapping('my_index', mockHttp); + expect(result).toBe(null); + }); +}); + +describe('extractIndexInfoFromDashboard', () => { + let mockSavedObjectsClient: jest.Mocked; + let mockHttp: jest.Mocked; + + beforeEach(() => { + mockSavedObjectsClient = ({ + get: jest.fn(), + } as unknown) as jest.Mocked; + mockHttp = ({ + get: jest.fn(), + } as unknown) as jest.Mocked; + }); + + it('returns null if panels have no savedObjectId', async () => { + const panels = { panel1: { explicitInput: {} } }; + const result = await extractIndexInfoFromDashboard(panels, mockSavedObjectsClient, mockHttp); + expect(result).toBe(null); + expect(mockSavedObjectsClient.get).not.toHaveBeenCalled(); + }); + + it('returns null if references include non-index-pattern types', async () => { + mockSavedObjectsClient.get.mockResolvedValueOnce({ + references: [{ type: 'data-source', id: 'ds-1', name: 'Data Source 1' }], + attributes: {}, + }); + const panels = { panel1: { explicitInput: { savedObjectId: 'so-1' }, type: 'visualization' } }; + const result = await extractIndexInfoFromDashboard(panels, mockSavedObjectsClient, mockHttp); + expect(result).toBe(null); + }); + + it('returns null if no index-pattern reference is found', async () => { + mockSavedObjectsClient.get.mockResolvedValueOnce({ + references: [{ type: 'other', id: 'other-1', name: 'Other 1' }], + attributes: {}, + }); + const panels = { panel1: { explicitInput: { savedObjectId: 'so-1' }, type: 'visualization' } }; + const result = await extractIndexInfoFromDashboard(panels, mockSavedObjectsClient, mockHttp); + expect(result).toBe(null); + }); + + it('returns null if concrete index cannot be resolved', async () => { + mockSavedObjectsClient.get + .mockResolvedValueOnce({ + references: [{ type: 'index-pattern', id: 'ip-1', name: 'Index Pattern 1' }], + attributes: {}, + }) + .mockResolvedValueOnce({ + attributes: { title: 'my_index*' }, + references: [], + }) + .mockResolvedValueOnce({ + attributes: { title: 'my_index*' }, + references: [], + }); + mockHttp.get.mockResolvedValueOnce({ indices: [] }); + const panels = { panel1: { explicitInput: { savedObjectId: 'so-1' }, type: 'visualization' } }; + const result = await extractIndexInfoFromDashboard(panels, mockSavedObjectsClient, mockHttp); + expect(result).toBe(null); + }); + + it('handles 404 saved object errors gracefully', async () => { + mockSavedObjectsClient.get.mockRejectedValueOnce({ response: { status: 404 } }); + const panels = { panel1: { explicitInput: { savedObjectId: 'so-1' }, type: 'visualization' } }; + const result = await extractIndexInfoFromDashboard(panels, mockSavedObjectsClient, mockHttp); + expect(result).toBe(null); + }); + + it('throws non-404 saved object errors', async () => { + mockSavedObjectsClient.get.mockRejectedValueOnce({ response: { status: 500 } }); + const panels = { panel1: { explicitInput: { savedObjectId: 'so-1' }, type: 'visualization' } }; + await expect( + extractIndexInfoFromDashboard(panels, mockSavedObjectsClient, mockHttp) + ).rejects.toMatchObject({ response: { status: 500 } }); + }); +}); diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync.ts b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync.ts new file mode 100644 index 000000000000..0134a5199f07 --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync.ts @@ -0,0 +1,217 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpStart, SavedObjectsClientContract } from 'src/core/public'; +import { i18n } from '@osd/i18n'; + +interface IndexExtractionResult { + datasource: string | null; + database: string | null; + index: string | null; +} + +export const DIRECT_QUERY_BASE = '/api/directquery'; +export const DSL_MAPPING = '/indices.getFieldMapping'; +export const DSL_BASE = `${DIRECT_QUERY_BASE}/dsl`; + +// Module for handling EMR states for Dashboards Progress Bar. All of these except "fresh" are +// directly from the EMR job run states. "ord" is used to approximate progress (eyeballed relative +// stage times), and "terminal" indicates whether a job is in progress at all. +export const EMR_STATES = new Map([ + ['submitted', { ord: 0, terminal: false }], + ['queued', { ord: 10, terminal: false }], + ['pending', { ord: 20, terminal: false }], + ['scheduled', { ord: 30, terminal: false }], + ['running', { ord: 70, terminal: false }], + ['cancelling', { ord: 90, terminal: false }], + ['success', { ord: 100, terminal: true }], + ['failed', { ord: 100, terminal: true }], + ['cancelled', { ord: 100, terminal: true }], + // The "null state" for a fresh page load, which components conditionally use on load. + ['fresh', { ord: 100, terminal: true }], +]); + +export const MAX_ORD = 100; + +export function intervalAsMinutes(interval: number): string { + const minutes = Math.floor(interval / 60000); + return minutes === 1 + ? i18n.translate('dashboard.directQuerySync.intervalAsMinutes.oneMinute', { + defaultMessage: '1 minute', + }) + : i18n.translate('dashboard.directQuerySync.intervalAsMinutes.multipleMinutes', { + defaultMessage: '{minutes} minutes', + values: { minutes }, + }); +} + +export async function resolveConcreteIndex( + indexTitle: string, + http: HttpStart, + mdsId?: string +): Promise { + if (!indexTitle.includes('*')) return indexTitle; + + try { + const query = mdsId ? { data_source: mdsId } : {}; + const resolved = await http.get( + `/internal/index-pattern-management/resolve_index/${encodeURIComponent(indexTitle)}`, + { query } + ); + const matchedIndices = resolved?.indices || []; + return matchedIndices.length > 0 ? matchedIndices[0].name : null; + } catch (err) { + return null; + } +} + +export function extractIndexParts(mappingName?: string): IndexExtractionResult { + // Use mapping name if provided; otherwise, return null values + if (mappingName) { + const parts = mappingName.split('.'); + return { + datasource: parts[0] || null, + database: parts[1] || null, + index: parts.slice(2).join('.') || null, + }; + } + + return { + datasource: null, + database: null, + index: null, + }; +} + +export function generateRefreshQuery(info: IndexExtractionResult): string { + // Ensure all required fields are non-null before constructing the query + if (!info.datasource || !info.database || !info.index) { + throw new Error( + 'Cannot generate refresh query: missing required datasource, database, or index' + ); + } + return `REFRESH MATERIALIZED VIEW \`${info.datasource}\`.\`${info.database}\`.\`${info.index}\``; +} + +/** + * Extracts index-related information from a dashboard's panels for direct query sync. + * Analyzes saved objects in the panels to identify a consistent index pattern use case of Integration Vended Dashboards, resolves it to a concrete index, + * fetches its mapping, and extracts datasource, database, and index details along with metadata like last refresh time. + * Returns null if the panels reference inconsistent index patterns, lack references, or if the index cannot be resolved. + */ +export async function extractIndexInfoFromDashboard( + panels: { [key: string]: any }, + savedObjectsClient: SavedObjectsClientContract, + http: HttpStart +): Promise<{ + parts: IndexExtractionResult; + mapping: { lastRefreshTime: number }; + mdsId?: string; +} | null> { + const indexPatternIds: string[] = []; + const mdsIds: Array = []; + + for (const panelId of Object.keys(panels)) { + try { + const panel = panels[panelId]; + const savedObjectId = panel.explicitInput?.savedObjectId; + if (!savedObjectId) continue; + + const type = panel.type; + const savedObject = await savedObjectsClient.get(type, savedObjectId); + + const references = savedObject.references || []; + + if (references.length === 0) { + continue; + } + + // Check if there is any non-index-pattern reference + if (references.some((ref: any) => ref.type !== 'index-pattern')) { + return null; + } + + const indexPatternRef = references.find((ref: any) => ref.type === 'index-pattern'); + if (!indexPatternRef) { + return null; + } + + const indexPattern = await savedObjectsClient.get('index-pattern', indexPatternRef.id); + const mdsId = + indexPattern.references?.find((ref) => ref.type === 'data-source')?.id || undefined; + + indexPatternIds.push(indexPatternRef.id); + mdsIds.push(mdsId); + } catch (err: any) { + // Ignore only 404 errors (missing saved object) + if (err?.response?.status !== 404) { + throw err; + } + } + } + + if (indexPatternIds.length === 0) { + return null; + } + + if (!sourceCheck(indexPatternIds, mdsIds)) { + return null; + } + + const selectedIndexPatternId = indexPatternIds[0]; + const selectedMdsId = mdsIds[0]; + + const indexPattern = await savedObjectsClient.get('index-pattern', selectedIndexPatternId); + const indexTitleRaw = indexPattern.attributes.title; + const concreteTitle = await resolveConcreteIndex(indexTitleRaw, http, selectedMdsId); + + if (!concreteTitle) return null; + + const mapping = await fetchIndexMapping(concreteTitle, http, selectedMdsId); + if (!mapping) return null; + + for (const val of Object.values(mapping)) { + const mappingName = val.mappings?._meta?.name; + return { + mapping: val.mappings._meta.properties!, + parts: extractIndexParts(mappingName), + mdsId: selectedMdsId, + }; + } + + return null; +} + +export async function fetchIndexMapping( + index: string, + http: HttpStart, + mdsId?: string +): Promise | null> { + try { + const baseUrl = `${DSL_BASE}${DSL_MAPPING}`; + const url = mdsId ? `${baseUrl}/dataSourceMDSId=${encodeURIComponent(mdsId)}` : baseUrl; + const response = await http.get(url, { + query: { index }, + }); + + return response; + } catch (err) { + return null; + } +} + +export function sourceCheck(indexPatternIds: string[], mdsIds: Array): boolean { + // If no visualizations reference an index pattern, treat as acceptable (no sync, but no conflict). + if (indexPatternIds.length === 0 && mdsIds.length === 0) { + return true; + } + + const uniqueIndexPatternIds = Array.from(new Set(indexPatternIds)); + const uniqueMdsIds = Array.from(new Set(mdsIds)); + + const isConsistent = uniqueIndexPatternIds.length === 1 && uniqueMdsIds.length <= 1; + + return isConsistent; +} diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_context.tsx b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_context.tsx new file mode 100644 index 000000000000..fa8c0704d77b --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_context.tsx @@ -0,0 +1,229 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { createContext, useState, useEffect, useContext, useMemo, Children } from 'react'; +import { + SavedObjectsClientContract, + HttpStart, + NotificationsStart, +} from 'opensearch-dashboards/public'; +import { Subscription } from 'rxjs'; +import isEqual from 'lodash/isEqual'; +import { + DirectQueryRequest, + DirectQueryLoadingStatus, +} from '../../../../../data_source_management/public'; +import { useDirectQuery } from '../../../../../data_source_management/public'; +import { DirectQuerySyncService, DirectQuerySyncUIProps } from './direct_query_sync_services'; +import { isDirectQuerySyncEnabledByUrl } from './direct_query_sync_url_flag'; +import { DashboardPanelState } from '../types'; +import { DashboardContainer } from '../dashboard_container'; +import { EMR_STATES } from './direct_query_sync'; +import { DashboardDirectQuerySync } from './dashboard_direct_query_sync'; + +interface DirectQuerySyncContextProps { + shouldRenderSyncUI: boolean; + syncUIProps: DirectQuerySyncUIProps; + loadStatus: DirectQueryLoadingStatus | undefined; + mdsId: string | undefined; +} + +export const DirectQuerySyncContext = createContext({ + shouldRenderSyncUI: false, + syncUIProps: { + lastRefreshTime: undefined, + refreshInterval: undefined, + onSynchronize: () => {}, + }, + loadStatus: undefined, + mdsId: undefined, +}); + +interface DirectQuerySyncProviderProps { + savedObjectsClient: SavedObjectsClientContract; + http: HttpStart; + notifications: NotificationsStart; + isDirectQuerySyncEnabled: boolean; + queryLang?: string; + container: DashboardContainer; + children: React.ReactNode; +} + +export const DirectQuerySyncProvider: React.FC = ({ + savedObjectsClient, + http, + notifications, + isDirectQuerySyncEnabled, + queryLang, + container, + children, +}) => { + console.log('DirectQuerySyncProvider: Mounting'); + console.log('DirectQuerySyncProvider: isDirectQuerySyncEnabled prop=', isDirectQuerySyncEnabled); + + // Check URL override + const urlOverride = isDirectQuerySyncEnabledByUrl(); + console.log('DirectQuerySyncProvider: URL override=', urlOverride); + + // Align feature enable/disable logic with syncService.isDirectQuerySyncEnabled() + const isFeatureEnabled = urlOverride !== undefined ? urlOverride : isDirectQuerySyncEnabled; + console.log('DirectQuerySyncProvider: isFeatureEnabled=', isFeatureEnabled); + + // Log the children with more detail + const childrenArray = Children.toArray(children); + console.log( + 'DirectQuerySyncProvider: children=', + childrenArray.map((child) => { + if (React.isValidElement(child)) { + return { + type: child.type?.displayName || child.type?.name || 'Unknown', + props: { + ...child.props, + // Avoid logging complex props that might cause circular reference issues + container: child.props.container ? '[DashboardContainer]' : undefined, + PanelComponent: child.props.PanelComponent ? '[Function]' : undefined, + onSynchronize: child.props.onSynchronize ? '[Function]' : undefined, + }, + }; + } + return child; + }) + ); + + const [mdsId, setMdsId] = useState(undefined); + const [panels, setPanels] = useState<{ [key: string]: DashboardPanelState }>( + container.getInput().panels + ); + + const { startLoading, loadStatus, pollingResult } = useDirectQuery(http, notifications, mdsId); + + const syncService = useMemo(() => { + console.log('Creating new DirectQuerySyncService'); + return new DirectQuerySyncService({ + savedObjectsClient, + http, + startLoading: (payload: DirectQueryRequest) => { + startLoading(payload); + }, + setMdsId: (newMdsId?: string) => { + setMdsId(newMdsId); + }, + isDirectQuerySyncEnabled, + queryLang, + }); + }, [savedObjectsClient, http, startLoading, setMdsId, isDirectQuerySyncEnabled, queryLang]); + + // Effect for subscribing to container input changes + useEffect(() => { + console.log('Subscription effect: container reference=', container); + const initialPanels = container.getInput().panels; + console.log('Subscription effect: Initial panels on mount=', Object.keys(initialPanels)); + setPanels(initialPanels); // Ensure initial panels are set + + const subscription: Subscription = container.getInput$().subscribe(() => { + const { panels: newPanels } = container.getInput(); + console.log('Subscription effect: newPanels=', Object.keys(newPanels)); + setPanels((prevPanels) => { + console.log('Subscription effect: prevPanels=', Object.keys(prevPanels)); + if (isEqual(prevPanels, newPanels)) { + console.log('Subscription effect: Panels unchanged, skipping update'); + return prevPanels; + } + console.log('Panels changed:', Object.keys(newPanels)); + return newPanels; + }); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [container]); + + // Effect for fetching metadata when panels change + useEffect(() => { + console.log('Fetch effect: syncService reference=', syncService); + console.log('Fetch effect: panels=', Object.keys(panels)); + if (syncService.isDirectQuerySyncEnabled()) { + console.log('Fetching metadata for panels:', Object.keys(panels)); + syncService.collectAllPanelMetadata(panels); + } else { + console.log('Not fetching metadata: syncService.isDirectQuerySyncEnabled()=false'); + } + }, [syncService, panels]); + + // Effect for handling EMR state polling and page reload + useEffect(() => { + if (!loadStatus) return; + + console.log('Polling effect: loadStatus=', loadStatus); + const emrState = EMR_STATES.get(loadStatus as string); + + if ( + emrState?.terminal && + loadStatus !== DirectQueryLoadingStatus.FRESH && + loadStatus !== DirectQueryLoadingStatus.FAILED && + loadStatus !== DirectQueryLoadingStatus.CANCELLED + ) { + console.log('Reloading page due to EMR state:', emrState); + window.location.reload(); + } + }, [loadStatus]); + + // Cleanup on unmount + useEffect(() => { + return () => { + console.log('DirectQuerySyncProvider: Unmounting'); + syncService.destroy(); + }; + }, [syncService]); + + const shouldRenderSyncUI = isFeatureEnabled; + const syncUIProps = syncService?.getSyncUIProps() ?? { + lastRefreshTime: undefined, + refreshInterval: undefined, + onSynchronize: () => {}, + }; + + console.log('DirectQuerySyncProvider: shouldRenderSyncUI=', shouldRenderSyncUI); + + const contextValue: DirectQuerySyncContextProps = { + shouldRenderSyncUI, + syncUIProps, + loadStatus, + mdsId, + }; + + console.log('DirectQuerySyncProvider: Providing context value:', { + shouldRenderSyncUI, + loadStatus, + mdsId, + }); + + // Render the sync bar as a decoration above the children (DashboardViewport) + const decoratedChildren = shouldRenderSyncUI ? ( + <> + + {children} + + ) : ( + children + ); + + return ( + + {decoratedChildren} + + ); +}; + +export const useDirectQuerySync = (): DirectQuerySyncContextProps => { + const context = useContext(DirectQuerySyncContext); + console.log('useDirectQuerySync: Consuming context value:', context); + return context; +}; diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_services.test.ts b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_services.test.ts new file mode 100644 index 000000000000..b26a662798cd --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_services.test.ts @@ -0,0 +1,657 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DirectQuerySyncService } from './direct_query_sync_services'; +import { extractIndexInfoFromDashboard, generateRefreshQuery } from './direct_query_sync'; +import { isDirectQuerySyncEnabledByUrl } from './direct_query_sync_url_flag'; + +// Mock dependencies +jest.mock('./direct_query_sync'); +jest.mock('./direct_query_sync_url_flag'); + +const mockRefreshQuery = 'REFRESH MATERIALIZED VIEW `datasource`.`database`.`index`'; + +describe('DirectQuerySyncService', () => { + let startLoadingSpy: jest.Mock; + let setMdsIdSpy: jest.Mock; + + beforeEach(() => { + startLoadingSpy = jest.fn(); + setMdsIdSpy = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('isDirectQuerySyncEnabled', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + // Reset mocks for each test to ensure isolation + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReset(); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + }); + + test('returns true when feature flag is enabled and no URL override', () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(undefined); + expect(service.isDirectQuerySyncEnabled()).toBe(true); + }); + + test('returns false when feature flag is disabled and no URL override', () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(undefined); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: undefined, + }); + expect(service.isDirectQuerySyncEnabled()).toBe(false); + }); + + test('returns true when URL override is true', () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + expect(service.isDirectQuerySyncEnabled()).toBe(true); + }); + + test('returns false when URL override is false', () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + expect(service.isDirectQuerySyncEnabled()).toBe(false); + }); + }); + + describe('getQueryLanguage', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReset(); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + }); + + test('returns sql when feature is enabled and queryLang is not provided', () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + expect(service.getQueryLanguage()).toBe('sql'); + }); + + test('returns empty string when feature is disabled and queryLang is not provided', () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: undefined, + }); + expect(service.getQueryLanguage()).toBe(''); + }); + + test('returns provided queryLang when specified, regardless of feature flag', () => { + // Feature flag disabled + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: 'ppl', + }); + expect(service.getQueryLanguage()).toBe('ppl'); + + // Feature flag enabled + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: 'ppl', + }); + expect(service.getQueryLanguage()).toBe('ppl'); + }); + + test('returns sql when queryLang is an empty string and feature is enabled', () => { + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: '', + }); + expect(service.getQueryLanguage()).toBe('sql'); + }); + + test('returns empty string when queryLang is an empty string and feature is disabled', () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: '', + }); + expect(service.getQueryLanguage()).toBe(''); + }); + }); + + describe('areDataSourceParamsValid', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + (extractIndexInfoFromDashboard as jest.Mock).mockReset(); + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + // Use jest.spyOn to mock private methods + jest.spyOn(DirectQuerySyncService.prototype as any, 'areDataSourceParamsValid'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('returns true for valid datasource params', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue({ + parts: { + datasource: 'datasource', + database: 'database', + index: 'index', + }, + mapping: { lastRefreshTime: 12345, refreshInterval: 30000 }, + mdsId: 'mds-1', + }); + + await service.collectAllPanelMetadata({} as any); + expect((service as any).areDataSourceParamsValid()).toBe(true); + }); + + test('returns false for invalid datasource params', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue(null); + + await service.collectAllPanelMetadata({} as any); + expect((service as any).areDataSourceParamsValid()).toBe(false); + }); + + test('returns false when only some params are missing', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue({ + parts: { + datasource: 'datasource', + database: null, // Missing database + index: 'index', + }, + mapping: { lastRefreshTime: 12345, refreshInterval: 30000 }, + mdsId: 'mds-1', + }); + + await service.collectAllPanelMetadata({} as any); + expect((service as any).areDataSourceParamsValid()).toBe(false); + }); + }); + + describe('shouldRenderSyncUI', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + (extractIndexInfoFromDashboard as jest.Mock).mockReset(); + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReset(); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + }); + + test('returns true when feature is enabled and extracted props are available', async () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue({ + parts: { + datasource: 'datasource', + database: 'database', + index: 'index', + }, + mapping: { lastRefreshTime: 12345, refreshInterval: 30000 }, + mdsId: 'mds-1', + }); + + await service.collectAllPanelMetadata({} as any); + expect(service.shouldRenderSyncUI()).toBe(true); + }); + + test('returns false when feature is disabled', () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: undefined, + }); + + expect(service.shouldRenderSyncUI()).toBe(false); + }); + + test('returns false when extracted props are not available', async () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue(null); + + await service.collectAllPanelMetadata({} as any); + expect(service.shouldRenderSyncUI()).toBe(false); + }); + }); + + describe('getSyncUIProps', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + (extractIndexInfoFromDashboard as jest.Mock).mockReset(); + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + // Ensure generateRefreshQuery is mocked for synchronizeNow calls + (generateRefreshQuery as jest.Mock).mockReturnValue(mockRefreshQuery); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + }); + + test('returns correct props when extracted props are available', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue({ + parts: { + datasource: 'datasource', + database: 'database', + index: 'index', + }, + mapping: { lastRefreshTime: 12345, refreshInterval: 30000 }, + mdsId: 'mds-1', + }); + + await service.collectAllPanelMetadata({} as any); + const props = service.getSyncUIProps(); + + expect(props).toEqual({ + lastRefreshTime: 12345, + refreshInterval: 30000, + onSynchronize: expect.any(Function), + }); + + // Verify onSynchronize calls synchronizeNow + props.onSynchronize(); + expect(startLoadingSpy).toHaveBeenCalledWith({ + query: mockRefreshQuery, + lang: 'sql', + datasource: 'datasource', + }); + }); + + test('returns undefined props when extracted props are not available', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue(null); + + await service.collectAllPanelMetadata({} as any); + const props = service.getSyncUIProps(); + + expect(props).toEqual({ + lastRefreshTime: undefined, + refreshInterval: undefined, + onSynchronize: expect.any(Function), + }); + }); + }); + + describe('synchronizeNow', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + (generateRefreshQuery as jest.Mock).mockReturnValue(mockRefreshQuery); + (extractIndexInfoFromDashboard as jest.Mock).mockReset(); + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + // Set up valid datasource params for all tests + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue({ + parts: { + datasource: 'datasource', + database: 'database', + index: 'index', + }, + mapping: { lastRefreshTime: 12345, refreshInterval: 30000 }, + mdsId: 'mds-1', + }); + jest.spyOn(DirectQuerySyncService.prototype as any, 'areDataSourceParamsValid'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('triggers REFRESH query generation and startLoading', async () => { + await service.collectAllPanelMetadata({} as any); + service.synchronizeNow(); + + expect(generateRefreshQuery).toHaveBeenCalledWith({ + datasource: 'datasource', + database: 'database', + index: 'index', + }); + expect(startLoadingSpy).toHaveBeenCalledWith({ + query: mockRefreshQuery, + lang: 'sql', + datasource: 'datasource', + }); + }); + + test('does nothing when feature flag is disabled', async () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: undefined, + }); + + await service.collectAllPanelMetadata({} as any); + service.synchronizeNow(); + expect(startLoadingSpy).not.toHaveBeenCalled(); + }); + + test('does nothing when datasource params are invalid', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue(null); + + await service.collectAllPanelMetadata({} as any); + service.synchronizeNow(); + + expect(startLoadingSpy).not.toHaveBeenCalled(); + }); + + test('uses sql when feature is enabled and queryLang is not provided', async () => { + await service.collectAllPanelMetadata({} as any); + service.synchronizeNow(); + + expect(startLoadingSpy).toHaveBeenCalledWith({ + query: mockRefreshQuery, + lang: 'sql', + datasource: 'datasource', + }); + }); + + test('uses provided queryLang when specified', async () => { + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: 'ppl', + }); + + await service.collectAllPanelMetadata({} as any); + service.synchronizeNow(); + + expect(startLoadingSpy).toHaveBeenCalledWith({ + query: mockRefreshQuery, + lang: 'ppl', + datasource: 'datasource', + }); + }); + + test('uses sql when queryLang is empty and feature is enabled', async () => { + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: '', + }); + + await service.collectAllPanelMetadata({} as any); + service.synchronizeNow(); + + expect(startLoadingSpy).toHaveBeenCalledWith({ + query: mockRefreshQuery, + lang: 'sql', + datasource: 'datasource', + }); + }); + + test('uses empty string when queryLang is empty and feature is disabled', async () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: '', + }); + + await service.collectAllPanelMetadata({} as any); + service.synchronizeNow(); + + expect(startLoadingSpy).not.toHaveBeenCalled(); + }); + }); + + describe('collectAllPanelMetadata', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + (extractIndexInfoFromDashboard as jest.Mock).mockReset(); + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReset(); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + }); + + test('sets extracted props and MDS ID when metadata is available', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue({ + parts: { + datasource: 'datasource', + database: 'database', + index: 'index', + }, + mapping: { lastRefreshTime: 12345, refreshInterval: 30000 }, + mdsId: 'mds-1', + }); + + await service.collectAllPanelMetadata({} as any); + + expect(service.getExtractedProps()).toEqual({ + lastRefreshTime: 12345, + refreshInterval: 30000, + }); + expect(setMdsIdSpy).toHaveBeenCalledWith('mds-1'); + }); + + test('clears extracted props and MDS ID when metadata is not available', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue(null); + + await service.collectAllPanelMetadata({} as any); + + expect(service.getExtractedProps()).toBeNull(); + expect(setMdsIdSpy).toHaveBeenCalledWith(undefined); + }); + + test('does not collect metadata when feature is disabled', async () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: undefined, + }); + + await service.collectAllPanelMetadata({} as any); + + expect(extractIndexInfoFromDashboard).not.toHaveBeenCalled(); + expect(service.getExtractedProps()).toBeNull(); + expect(setMdsIdSpy).not.toHaveBeenCalled(); + }); + + test('handles errors from extractIndexInfoFromDashboard', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockRejectedValue( + new Error('Failed to extract index info') + ); + + await service.collectAllPanelMetadata({} as any); + + expect(service.getExtractedProps()).toBeNull(); + expect(setMdsIdSpy).toHaveBeenCalledWith(undefined); + }); + + test('updates extracted props on subsequent calls with different metadata', async () => { + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValueOnce({ + parts: { + datasource: 'datasource1', + database: 'database1', + index: 'index1', + }, + mapping: { lastRefreshTime: 12345, refreshInterval: 30000 }, + mdsId: 'mds-1', + }); + + await service.collectAllPanelMetadata({} as any); + expect(service.getExtractedProps()).toEqual({ + lastRefreshTime: 12345, + refreshInterval: 30000, + }); + expect(setMdsIdSpy).toHaveBeenCalledWith('mds-1'); + + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValueOnce({ + parts: { + datasource: 'datasource2', + database: 'database2', + index: 'index2', + }, + mapping: { lastRefreshTime: 67890, refreshInterval: 60000 }, + mdsId: 'mds-2', + }); + + await service.collectAllPanelMetadata({} as any); + expect(service.getExtractedProps()).toEqual({ + lastRefreshTime: 67890, + refreshInterval: 60000, + }); + expect(setMdsIdSpy).toHaveBeenCalledWith('mds-2'); + }); + }); + + describe('updatePanels', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReset(); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + }); + + test('calls collectAllPanelMetadata when feature is enabled', async () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(true); + (extractIndexInfoFromDashboard as jest.Mock).mockResolvedValue({ + parts: { + datasource: 'datasource', + database: 'database', + index: 'index', + }, + mapping: { lastRefreshTime: 12345, refreshInterval: 30000 }, + mdsId: 'mds-1', + }); + + const spy = jest.spyOn(service, 'collectAllPanelMetadata'); + await service.updatePanels({} as any); + + expect(spy).toHaveBeenCalledWith({}); + }); + + test('does not call collectAllPanelMetadata when feature is disabled', async () => { + (isDirectQuerySyncEnabledByUrl as jest.Mock).mockReturnValue(false); + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: false, + queryLang: undefined, + }); + + const spy = jest.spyOn(service, 'collectAllPanelMetadata'); + await service.updatePanels({} as any); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + let service: DirectQuerySyncService; + + beforeEach(() => { + service = new DirectQuerySyncService({ + savedObjectsClient: {} as any, + http: {} as any, + startLoading: startLoadingSpy, + setMdsId: setMdsIdSpy, + isDirectQuerySyncEnabled: true, + queryLang: undefined, + }); + }); + + test('does not throw errors', () => { + expect(() => service.destroy()).not.toThrow(); + }); + }); +}); diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_services.ts b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_services.ts new file mode 100644 index 000000000000..4ae810f306eb --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_services.ts @@ -0,0 +1,183 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract, HttpStart } from 'src/core/public'; +import { DirectQueryRequest } from '../../../../../data_source_management/public'; +import { extractIndexInfoFromDashboard, generateRefreshQuery } from './direct_query_sync'; +import { isDirectQuerySyncEnabledByUrl } from './direct_query_sync_url_flag'; +import { DashboardPanelState } from '..'; + +interface DirectQuerySyncServiceProps { + savedObjectsClient: SavedObjectsClientContract; + http: HttpStart; + startLoading: (payload: DirectQueryRequest) => void; + setMdsId?: (mdsId?: string) => void; + isDirectQuerySyncEnabled: boolean; + queryLang?: string; +} + +interface DirectQuerySyncState { + extractedProps: { lastRefreshTime?: number; refreshInterval?: number } | null; + panelMetadata: Array<{ panelId: string; savedObjectId: string; type: string }>; +} + +export interface DirectQuerySyncUIProps { + lastRefreshTime?: number; + refreshInterval?: number; + onSynchronize: () => void; +} + +export class DirectQuerySyncService { + private savedObjectsClient: SavedObjectsClientContract; + private http: HttpStart; + private startLoading: (payload: DirectQueryRequest) => void; + private setMdsId?: (mdsId?: string) => void; + private isDirectQuerySyncEnabledProp: boolean; + private queryLang?: string; + + private extractedDatasource: string | null = null; + private extractedDatabase: string | null = null; + private extractedIndex: string | null = null; + private state: DirectQuerySyncState = { + extractedProps: null, + panelMetadata: [], + }; + + constructor(props: DirectQuerySyncServiceProps) { + this.savedObjectsClient = props.savedObjectsClient; + this.http = props.http; + this.startLoading = props.startLoading; + this.setMdsId = props.setMdsId; + this.isDirectQuerySyncEnabledProp = props.isDirectQuerySyncEnabled; + this.queryLang = props.queryLang; + } + + /** + * Determines if direct query sync is enabled, considering URL overrides. + */ + public isDirectQuerySyncEnabled(): boolean { + const urlOverride = isDirectQuerySyncEnabledByUrl(); + return urlOverride !== undefined ? urlOverride : this.isDirectQuerySyncEnabledProp; + } + + /** + * Determines the query language to use for direct query sync. + * Returns the provided queryLang if specified and non-empty; otherwise, defaults to 'sql' if the feature is enabled. + */ + public getQueryLanguage(): string { + if (this.queryLang !== undefined && this.queryLang !== '') { + return this.queryLang; + } + return this.isDirectQuerySyncEnabled() ? 'sql' : ''; + } + + /** + * Validates if the extracted datasource, database, and index are present and valid. + * Returns true if all values are non-null, false otherwise. + */ + private areDataSourceParamsValid(): boolean { + return !!this.extractedDatasource && !!this.extractedDatabase && !!this.extractedIndex; + } + + /** + * Collects metadata (panelId, savedObjectId, type) for all panels in the dashboard. + * Updates the service state with extracted props and sets MDS ID if applicable. + */ + public async collectAllPanelMetadata(panels: { [key: string]: DashboardPanelState }) { + if (!this.isDirectQuerySyncEnabled()) return; + + let indexInfo; + try { + indexInfo = await extractIndexInfoFromDashboard(panels, this.savedObjectsClient, this.http); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Caught error in collectAllPanelMetadata:', error); + indexInfo = null; + } + + if (indexInfo) { + this.extractedDatasource = indexInfo.parts.datasource; + this.extractedDatabase = indexInfo.parts.database; + this.extractedIndex = indexInfo.parts.index; + this.state.extractedProps = indexInfo.mapping; + if (this.setMdsId) { + this.setMdsId(indexInfo.mdsId); + } + } else { + this.extractedDatasource = null; + this.extractedDatabase = null; + this.extractedIndex = null; + this.state.extractedProps = null; + if (this.setMdsId) { + this.setMdsId(undefined); + } + } + } + + /** + * Initiates a direct query sync to refresh the dashboard data. + * Uses the extracted datasource, database, and index to construct a refresh query, + * and triggers the sync process if direct query sync is enabled. + */ + public synchronizeNow = () => { + if (!this.isDirectQuerySyncEnabled() || !this.areDataSourceParamsValid()) return; + + const query = generateRefreshQuery({ + datasource: this.extractedDatasource!, + database: this.extractedDatabase!, + index: this.extractedIndex!, + }); + + this.startLoading({ + query, + lang: this.getQueryLanguage(), + datasource: this.extractedDatasource!, + }); + }; + + /** + * Returns the current state of extracted properties (lastRefreshTime, refreshInterval). + */ + public getExtractedProps(): { lastRefreshTime?: number; refreshInterval?: number } | null { + return this.state.extractedProps; + } + + /** + * Determines if the direct query sync UI should be rendered. + * Returns true if the feature is enabled and extracted props are available. + */ + public shouldRenderSyncUI(): boolean { + const extractedProps = this.getExtractedProps(); + return this.isDirectQuerySyncEnabled() && extractedProps !== null; + } + + /** + * Returns the props needed to render the direct query sync UI (excluding loadStatus). + */ + public getSyncUIProps(): DirectQuerySyncUIProps { + const extractedProps = this.getExtractedProps(); + return { + lastRefreshTime: extractedProps?.lastRefreshTime, + refreshInterval: extractedProps?.refreshInterval, + onSynchronize: () => this.synchronizeNow(), + }; + } + + /** + * Updates the service with new panels, triggering metadata collection if needed. + */ + public updatePanels(panels: { [key: string]: DashboardPanelState }) { + if (this.isDirectQuerySyncEnabled()) { + this.collectAllPanelMetadata(panels); + } + } + + /** + * Cleans up any resources or subscriptions (if added in the future). + */ + public destroy() { + // Currently, no subscriptions to clean up, but this method can be extended later. + } +} diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_url_flag.test.ts b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_url_flag.test.ts new file mode 100644 index 000000000000..7b1a5d826b1d --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_url_flag.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isDirectQuerySyncEnabledByUrl } from './direct_query_sync_url_flag'; + +describe('isDirectQuerySyncEnabledByUrl', () => { + const originalLocation = window.location; + + beforeEach(() => { + // @ts-ignore + delete window.location; + window.location = { + hash: '', + } as any; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it('returns true when URL param is true', () => { + window.location.hash = '#/view/someid?dashboard.directQueryConnectionSync=true'; + expect(isDirectQuerySyncEnabledByUrl()).toBe(true); + }); + + it('returns false when URL param is false', () => { + window.location.hash = '#/view/someid?dashboard.directQueryConnectionSync=false'; + expect(isDirectQuerySyncEnabledByUrl()).toBe(false); + }); + + it('returns undefined when URL param is missing', () => { + window.location.hash = '#/view/someid'; + expect(isDirectQuerySyncEnabledByUrl()).toBe(undefined); + }); + + it('returns undefined when URL param is not true/false', () => { + window.location.hash = '#/view/someid?dashboard.directQueryConnectionSync=maybe'; + expect(isDirectQuerySyncEnabledByUrl()).toBe(undefined); + }); + + it('handles multiple URL params correctly', () => { + window.location.hash = + '#/view/someid?otherparam=abc&dashboard.directQueryConnectionSync=true&anotherparam=xyz'; + expect(isDirectQuerySyncEnabledByUrl()).toBe(true); + }); +}); diff --git a/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_url_flag.ts b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_url_flag.ts new file mode 100644 index 000000000000..dc00bf31fe53 --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/direct_query_sync/direct_query_sync_url_flag.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export function isDirectQuerySyncEnabledByUrl(): boolean | undefined { + const hash = window.location.hash; + const queryString = hash.includes('?') ? hash.substring(hash.indexOf('?')) : ''; + const urlParams = new URLSearchParams(queryString); + + const param = urlParams.get('dashboard.directQueryConnectionSync'); + + if (param === 'true') return true; + if (param === 'false') return false; + return undefined; +} diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx index 7d286f6fbf49..7569dd9b6e25 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx @@ -74,7 +74,9 @@ function getProps( getEmbeddableFactory: start.getEmbeddableFactory, } as any, chrome: {} as any, - notifications: {} as any, + notifications: { + toasts: { addDanger: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn() }, + } as any, overlays: {} as any, inspector: { isAvailable: jest.fn(), @@ -106,6 +108,19 @@ function getProps( container: dashboardContainer, logos: options.chrome.logos, PanelComponent: () =>
, + savedObjectsClient: { + get: jest.fn().mockResolvedValue({ + attributes: { title: 'test-pattern' }, + references: [], + }), + find: jest.fn(), + } as any, + http: { get: jest.fn() } as any, + notifications: options.notifications, + startLoading: jest.fn(), + loadStatus: 'fresh', + pollingResult: {}, + isDirectQuerySyncEnabled: false, }; return { diff --git a/src/plugins/dashboard/public/application/utils/mocks.ts b/src/plugins/dashboard/public/application/utils/mocks.ts index 529808eb6258..52731f3b3e10 100644 --- a/src/plugins/dashboard/public/application/utils/mocks.ts +++ b/src/plugins/dashboard/public/application/utils/mocks.ts @@ -40,6 +40,10 @@ export const createDashboardServicesMock = () => { embeddable, savedObjectsClient: { find: jest.fn(), + get: jest.fn().mockResolvedValue({ + attributes: { title: 'flint_ds1_db1_index1' }, + references: [{ type: 'data-source', id: 'test-mds' }], + }), }, savedObjectsPublic: { settings: { diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 55ed9a02d24d..ef8fcb5be91d 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -138,6 +138,7 @@ export type DashboardUrlGenerator = UrlGeneratorContract void; addBasePath?: (url: string) => string; toastNotifications: ToastsStart; + dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; } diff --git a/src/plugins/data_source_management/framework/hooks/direct_query_hook.tsx b/src/plugins/data_source_management/framework/hooks/direct_query_hook.tsx index a2b05f47e9ee..e6250f784c48 100644 --- a/src/plugins/data_source_management/framework/hooks/direct_query_hook.tsx +++ b/src/plugins/data_source_management/framework/hooks/direct_query_hook.tsx @@ -19,7 +19,7 @@ export const useDirectQuery = ( ) => { const sqlService = new SQLService(http); const [loadStatus, setLoadStatus] = useState( - DirectQueryLoadingStatus.SCHEDULED + DirectQueryLoadingStatus.FRESH ); const { diff --git a/src/plugins/data_source_management/framework/types.tsx b/src/plugins/data_source_management/framework/types.tsx index 34174a6c4b0e..73c83d85ad0b 100644 --- a/src/plugins/data_source_management/framework/types.tsx +++ b/src/plugins/data_source_management/framework/types.tsx @@ -9,10 +9,12 @@ export enum DirectQueryLoadingStatus { SUCCESS = 'success', FAILED = 'failed', RUNNING = 'running', + SUBMITTED = 'submitted', SCHEDULED = 'scheduled', - CANCELED = 'canceled', + CANCELLED = 'cancelled', WAITING = 'waiting', INITIAL = 'initial', + FRESH = 'fresh', } export interface DirectQueryRequest { diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_button.test.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_button.test.tsx index 135d6fa0e1d8..e836ea9b1c8d 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_button.test.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_button.test.tsx @@ -185,7 +185,7 @@ describe('CreateAccelerationButton', () => { test('handles other statuses correctly', async () => { const statuses = [ DirectQueryLoadingStatus.FAILED, - DirectQueryLoadingStatus.CANCELED, + DirectQueryLoadingStatus.CANCELLED, DirectQueryLoadingStatus.RUNNING, ]; diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_button.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_button.tsx index e7f021af2399..a6780d47fe89 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_button.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_button.tsx @@ -61,16 +61,25 @@ export const CreateAccelerationButton = ({ useEffect(() => { const status = directqueryLoadStatus.toLowerCase(); - if (status === DirectQueryLoadingStatus.SUCCESS) { + if (status === DirectQueryLoadingStatus.SUCCESS.toLowerCase()) { setIsLoading(false); notifications.toasts.addSuccess('Create acceleration query submitted successfully!'); if (refreshHandler) refreshHandler(); resetFlyout(); } else if ( - status === DirectQueryLoadingStatus.FAILED || - status === DirectQueryLoadingStatus.CANCELED + status === DirectQueryLoadingStatus.FAILED.toLowerCase() || + status === DirectQueryLoadingStatus.CANCELLED.toLowerCase() || + status === DirectQueryLoadingStatus.INITIAL.toLowerCase() || + status === DirectQueryLoadingStatus.FRESH.toLowerCase() ) { setIsLoading(false); + } else if ( + status === DirectQueryLoadingStatus.SUBMITTED.toLowerCase() || + status === DirectQueryLoadingStatus.WAITING.toLowerCase() || + status === DirectQueryLoadingStatus.RUNNING.toLowerCase() || + status === DirectQueryLoadingStatus.SCHEDULED.toLowerCase() + ) { + setIsLoading(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [directqueryLoadStatus]); diff --git a/src/plugins/data_source_management/public/components/frame_work_test/hooks/drect_query_hook.test.tsx b/src/plugins/data_source_management/public/components/frame_work_test/hooks/drect_query_hook.test.tsx index c68906511bbb..7d088ee54b0f 100644 --- a/src/plugins/data_source_management/public/components/frame_work_test/hooks/drect_query_hook.test.tsx +++ b/src/plugins/data_source_management/public/components/frame_work_test/hooks/drect_query_hook.test.tsx @@ -68,9 +68,9 @@ describe('useDirectQuery', () => { })); }); - it('should initialize with scheduled status', () => { + it('should initialize with fresh status', () => { const { result } = renderHook(() => useDirectQuery(httpMock, notificationsMock)); - expect(result.current.loadStatus).toBe(DirectQueryLoadingStatus.SCHEDULED); + expect(result.current.loadStatus).toBe(DirectQueryLoadingStatus.FRESH); }); it('should handle successful query execution and start polling', async () => { diff --git a/src/plugins/data_source_management/public/index.ts b/src/plugins/data_source_management/public/index.ts index 11c2864ec86f..757eeb13b44c 100644 --- a/src/plugins/data_source_management/public/index.ts +++ b/src/plugins/data_source_management/public/index.ts @@ -27,3 +27,12 @@ export { export { DataSourceSelectionService } from './service/data_source_selection_service'; export { getDefaultDataSourceId, getDefaultDataSourceId$ } from './components/utils'; export { DATACONNECTIONS_BASE, DatasourceTypeToDisplayName } from './constants'; + +// Export framework utilities +export { usePolling, UsePolling, PollingConfigurations } from '../framework/utils/use_polling'; +export { SQLService } from '../framework/requests/sql'; +export { useDirectQuery } from '../framework/hooks/direct_query_hook'; +export { DirectQueryRequest, DirectQueryLoadingStatus } from '../framework/types'; +export { getAsyncSessionId, setAsyncSessionId } from '../framework/utils/query_session_utils'; +export { formatError } from '../framework/utils/shared'; +export { ASYNC_POLLING_INTERVAL } from '../framework/constants'; diff --git a/tsconfig.base.json b/tsconfig.base.json index d122bd79a5de..ddde899cf4c0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -11,6 +11,8 @@ "plugins/*": ["src/legacy/core_plugins/*/public/"], "test_utils/*": ["src/test_utils/public/*"], "fixtures/*": ["src/fixtures/*"], + "data_source_management/public": ["src/plugins/data_source_management/public"], + "data_source_management/public/*": ["src/plugins/data_source_management/public/*"], "@opensearch-project/opensearch": ["node_modules/@opensearch-project/opensearch/api/new"], "@opensearch-project/opensearch/lib/*": ["node_modules/@opensearch-project/opensearch/lib/*"], },