diff --git a/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts b/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts index b2a6ae6fda65..2b09a1c853c4 100644 --- a/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts +++ b/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts @@ -11,6 +11,7 @@ import { checkConflictsForDataSource, ConflictsForDataSourceParams, } from './check_conflict_for_data_source'; +import { VisualizationObject } from './types'; type SavedObjectType = SavedObject<{ title?: string }>; @@ -40,6 +41,23 @@ const createVegaVisualizationObject = (id: string): SavedObjectType => { } as SavedObjectType; }; +const createTSVBVisualizationObject = (id: string): VisualizationObject => { + const idParse = id.split('_'); + const params = idParse.length > 1 ? { data_source_id: idParse[1] } : {}; + const visState = { + type: 'metrics', + params, + }; + + return { + type: 'visualization', + id, + attributes: { title: 'some-title', visState: JSON.stringify(visState) }, + references: + idParse.length > 1 ? [{ id: idParse[1], type: 'data-source', name: 'dataSource' }] : [], + } as VisualizationObject; +}; + const getSavedObjectClient = (): SavedObjectsClientContract => { const savedObject = {} as SavedObjectsClientContract; savedObject.get = jest.fn().mockImplementation((type, id) => { @@ -299,4 +317,57 @@ describe('#checkConflictsForDataSource', () => { }) ); }); + + /** + * TSVB test cases + */ + it.each([ + { + id: 'some-object-id', + }, + { + id: 'old-datasource-id_some-object-id', + }, + ])('will update datasource reference + visState of TSVB visualization', async ({ id }) => { + const tsvbSavedObject = createTSVBVisualizationObject(id); + const expectedVisState = JSON.parse(tsvbSavedObject.attributes.visState); + expectedVisState.params.data_source_id = 'some-datasource-id'; + const newVisState = JSON.stringify(expectedVisState); + const params = setupParams({ + objects: [tsvbSavedObject], + ignoreRegularConflicts: true, + dataSourceId: 'some-datasource-id', + savedObjectsClient: getSavedObjectClient(), + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + + expect(checkConflictsForDataSourceResult).toEqual( + expect.objectContaining({ + filteredObjects: [ + { + ...tsvbSavedObject, + attributes: { + title: 'some-title', + visState: newVisState, + }, + id: 'some-datasource-id_some-object-id', + references: [ + { + id: 'some-datasource-id', + name: 'dataSource', + type: 'data-source', + }, + ], + }, + ], + errors: [], + importIdMap: new Map([ + [ + `visualization:some-object-id`, + { id: 'some-datasource-id_some-object-id', omitOriginId: true }, + ], + ]), + }) + ); + }); }); diff --git a/src/core/server/saved_objects/import/check_conflict_for_data_source.ts b/src/core/server/saved_objects/import/check_conflict_for_data_source.ts index a0400c57d023..f04dd0c6f69e 100644 --- a/src/core/server/saved_objects/import/check_conflict_for_data_source.ts +++ b/src/core/server/saved_objects/import/check_conflict_for_data_source.ts @@ -9,9 +9,11 @@ import { SavedObjectsImportError, SavedObjectsImportRetry, } from '../types'; +import { VisualizationObject } from './types'; import { extractVegaSpecFromSavedObject, getDataSourceTitleFromId, + getUpdatedTSVBVisState, updateDataSourceNameInVegaSpec, } from './utils'; @@ -117,6 +119,16 @@ export async function checkConflictsForDataSource({ }); } } + + if (!!dataSourceId) { + const visualizationObject = object as VisualizationObject; + const { visState, references } = getUpdatedTSVBVisState( + visualizationObject, + dataSourceId + ); + visualizationObject.attributes.visState = visState; + object.references = references; + } } const omitOriginId = ignoreRegularConflicts; diff --git a/src/core/server/saved_objects/import/create_saved_objects.test.ts b/src/core/server/saved_objects/import/create_saved_objects.test.ts index fa723d225508..da7f057435ad 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -76,6 +76,7 @@ const dataSourceObj1 = createObject(DATA_SOURCE, 'ds-id1'); // -> success const dataSourceObj2 = createObject(DATA_SOURCE, 'ds-id2'); // -> conflict const dashboardObjWithDataSource = createObject('dashboard', 'ds_dashboard-id1'); // -> success const visualizationObjWithDataSource = createObject('visualization', 'ds_visualization-id1'); // -> success +visualizationObjWithDataSource.attributes = { visState: '{}' }; const searchObjWithDataSource = createObject('search', 'ds_search-id1'); // -> success // objs without data source id, used to test can get saved object with data source id @@ -100,6 +101,7 @@ const visualizationObj = { type: 'visualization', attributes: { title: 'visualization-title', + visState: '{}', }, references: [], source: { @@ -130,6 +132,27 @@ const getVegaVisualizationObj = (id: string) => ({ updated_at: 'some-date', }); +const getTSVBVisualizationObj = (id: string, dataSourceId?: string) => { + const params = dataSourceId ? { data_source_id: dataSourceId } : {}; + const references = dataSourceId + ? [{ id: dataSourceId, name: 'dataSource', type: 'data-source' }] + : []; + return { + type: 'visualization', + id, + attributes: { + title: 'some-title', + visState: JSON.stringify({ + type: 'metrics', + params, + }), + }, + references, + namespaces: ['default'], + updated_at: 'some-date', + }; +}; + const getVegaMDSVisualizationObj = (id: string, dataSourceId: string) => ({ type: 'visualization', id: dataSourceId ? `${dataSourceId}_${id}` : id, @@ -500,6 +523,30 @@ describe('#createSavedObjects', () => { expect(results).toEqual(expectedResults); }; + const testTSVBVisualizationsWithDataSources = async (params: { + objects: SavedObject[]; + expectedFilteredObjects: SavedObject[]; + dataSourceId?: string; + dataSourceTitle?: string; + }) => { + const savedObjectsCustomClient = savedObjectsClientMock.create(); + + const options = setupParams({ + ...params, + savedObjectsCustomClient, + }); + + savedObjectsCustomClient.bulkCreate = jest.fn().mockImplementation((objectsToCreate, _) => { + return Promise.resolve({ + saved_objects: objectsToCreate, + }); + }); + + const results = await createSavedObjects(options); + + expect(results.createdObjects).toMatchObject(params.expectedFilteredObjects); + }; + const testReturnValueWithDataSource = async ( namespace?: string, dataSourceId?: string, @@ -661,6 +708,46 @@ describe('#createSavedObjects', () => { }); }); + describe('with a data source for TSVB saved objects', () => { + test('can attach a TSVB datasource reference to a non-MDS ', async () => { + const objects = [getTSVBVisualizationObj('some-tsvb-id')]; + const expectedObject = getTSVBVisualizationObj('some-tsvb-id', 'some-datasource-id'); + const expectedFilteredObjects = [ + { + ...expectedObject, + attributes: { + title: 'some-title_dataSourceName', + }, + }, + ]; + await testTSVBVisualizationsWithDataSources({ + objects, + expectedFilteredObjects, + dataSourceId: 'some-datasource-id', + dataSourceTitle: 'dataSourceName', + }); + }); + + test('can update a TSVB datasource reference', async () => { + const objects = [getTSVBVisualizationObj('some-tsvb-id', 'old-datasource-id')]; + const expectedObject = getTSVBVisualizationObj('some-tsvb-id', 'some-datasource-id'); + const expectedFilteredObjects = [ + { + ...expectedObject, + attributes: { + title: 'some-title_dataSourceName', + }, + }, + ]; + await testTSVBVisualizationsWithDataSources({ + objects, + expectedFilteredObjects, + dataSourceId: 'some-datasource-id', + dataSourceTitle: 'dataSourceName', + }); + }); + }); + describe('with a undefined workspaces', () => { test('calls bulkCreate once with input objects', async () => { const options = setupParams({ objects: objs }); diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index 89b751b3ff28..7e3854107a29 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -35,8 +35,12 @@ import { SavedObjectsImportError, } from '../types'; import { extractErrors } from './extract_errors'; -import { CreatedObject } from './types'; -import { extractVegaSpecFromSavedObject, updateDataSourceNameInVegaSpec } from './utils'; +import { CreatedObject, VisualizationObject } from './types'; +import { + extractVegaSpecFromSavedObject, + getUpdatedTSVBVisState, + updateDataSourceNameInVegaSpec, +} from './utils'; interface CreateSavedObjectsParams { objects: Array>; @@ -125,6 +129,15 @@ export const createSavedObjects = async ({ name: 'dataSource', }); } + + const visualizationObject = object as VisualizationObject; + const { visState, references } = getUpdatedTSVBVisState( + visualizationObject, + dataSourceId + ); + + visualizationObject.attributes.visState = visState; + object.references = references; } if (object.type === 'index-pattern') { diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index a243e08f83e0..426d4cfde86c 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -219,3 +219,5 @@ export interface SavedObjectsResolveImportErrorsOptions { } export type CreatedObject = SavedObject & { destinationId?: string }; + +export type VisualizationObject = SavedObject & { attributes: { visState: string } }; diff --git a/src/core/server/saved_objects/import/utils.test.ts b/src/core/server/saved_objects/import/utils.test.ts index 604b6f6d473f..36dd427377a9 100644 --- a/src/core/server/saved_objects/import/utils.test.ts +++ b/src/core/server/saved_objects/import/utils.test.ts @@ -7,6 +7,7 @@ import { readFileSync } from 'fs'; import { extractVegaSpecFromSavedObject, getDataSourceTitleFromId, + getUpdatedTSVBVisState, updateDataSourceNameInVegaSpec, } from './utils'; import { parse } from 'hjson'; @@ -245,3 +246,66 @@ describe('getDataSourceTitleFromId()', () => { expect(await getDataSourceTitleFromId('nonexistent-id', savedObjectsClient)).toBe(undefined); }); }); + +describe('getUpdatedTSVBVisState', () => { + const getTSVBSavedObject = (dataSourceId?: string) => { + const params = dataSourceId ? { data_source_id: dataSourceId } : {}; + const references = dataSourceId + ? [{ id: dataSourceId, type: 'data-source', name: 'dataSource' }] + : []; + + return { + type: 'visualization', + id: 'some-id', + attributes: { + title: 'Some Title', + visState: JSON.stringify({ + type: 'metrics', + params, + }), + }, + references, + }; + }; + + test('non-TSVB object should return the old references and visState', () => { + const visState = { + type: 'area', + params: {}, + }; + + const object = { + type: 'visualization', + id: 'some-id', + attributes: { + title: 'Some title', + visState: JSON.stringify(visState), + }, + references: [], + }; + + expect(getUpdatedTSVBVisState(object, 'some-datasource-id')).toMatchObject({ + visState: JSON.stringify(visState), + references: [], + }); + }); + + test.each(['old-datasource-id', undefined])( + `non-MDS TSVB object should update the datasource when the old datasource is "%s"`, + (oldDataSourceId) => { + const object = getTSVBSavedObject(oldDataSourceId); + const dataSourceId = 'some-datasource-id'; + const expectedVisState = JSON.stringify({ + type: 'metrics', + params: { + data_source_id: dataSourceId, + }, + }); + + expect(getUpdatedTSVBVisState(object, dataSourceId)).toMatchObject({ + visState: expectedVisState, + references: [{ id: dataSourceId, name: 'dataSource', type: 'data-source' }], + }); + } + ); +}); diff --git a/src/core/server/saved_objects/import/utils.ts b/src/core/server/saved_objects/import/utils.ts index 10481c8227b6..92835ec017b3 100644 --- a/src/core/server/saved_objects/import/utils.ts +++ b/src/core/server/saved_objects/import/utils.ts @@ -4,7 +4,8 @@ */ import { parse, stringify } from 'hjson'; -import { SavedObject, SavedObjectsClientContract } from '../types'; +import { SavedObject, SavedObjectReference, SavedObjectsClientContract } from '../types'; +import { VisualizationObject } from './types'; /** * Given a Vega spec, the new datasource (by name), and spacing, update the Vega spec to add the new datasource name to each local cluster query @@ -19,6 +20,44 @@ export interface UpdateDataSourceNameInVegaSpecProps { spacing?: number; } +/** + * Given a visualization saved object and datasource id, return the updated visState and references if the visualization was a TSVB visualization + * @param {VisualizationObject} object + * @param {string} dataSourceId + * @returns {{visState: string, references: SavedObjectReference[]}} - the updated stringified visState and references + */ +export const getUpdatedTSVBVisState = ( + object: VisualizationObject, + dataSourceId: string +): { visState: string; references: SavedObjectReference[] } => { + const visStateObject = JSON.parse(object.attributes.visState); + + if (visStateObject.type !== 'metrics') { + return { + visState: object.attributes.visState, + references: object.references, + }; + } + + const oldDataSourceId = visStateObject.params.data_source_id; + const newReferences = object.references.filter((reference) => { + return reference.id !== oldDataSourceId && reference.type === 'data-source'; + }); + + visStateObject.params.data_source_id = dataSourceId; + + newReferences.push({ + id: dataSourceId, + name: 'dataSource', + type: 'data-source', + }); + + return { + visState: JSON.stringify(visStateObject), + references: newReferences, + }; +}; + export const updateDataSourceNameInVegaSpec = ( props: UpdateDataSourceNameInVegaSpecProps ): string => { diff --git a/src/plugins/vis_type_timeseries/server/lib/services.ts b/src/plugins/vis_type_timeseries/server/lib/services.ts new file mode 100644 index 000000000000..13b257622abd --- /dev/null +++ b/src/plugins/vis_type_timeseries/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_timeseries/server/lib/test_utils/test_params.json b/src/plugins/vis_type_timeseries/server/lib/test_utils/test_params.json new file mode 100644 index 000000000000..e13967a41eb1 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/test_utils/test_params.json @@ -0,0 +1,154 @@ +{ + "withDataSourceFieldNonEmpty": { + "id": "61ca57f0-469d-11e7-af02-69e470af7417", + "type": "timeseries", + "series": [ + { + "id": "61ca57f1-469d-11e7-af02-69e470af7417", + "color": "#54B399", + "split_mode": "everything", + "split_color_mode": "opensearchDashboards", + "metrics": [ + { + "id": "61ca57f2-469d-11e7-af02-69e470af7417", + "type": "avg", + "field": "bytes" + } + ], + "separate_axis": 0, + "axis_position": "right", + "formatter": "number", + "chart_type": "line", + "line_width": 1, + "point_size": 1, + "fill": "", + "stacked": "none", + "label": "", + "type": "timeseries" + } + ], + "time_field": "timestamp", + "index_pattern": "opensearch_dashboards_sample_data_logs", + "interval": "", + "axis_position": "left", + "axis_formatter": "number", + "axis_scale": "normal", + "show_legend": 1, + "show_grid": 1, + "tooltip_mode": "show_all", + "default_index_pattern": "opensearch_dashboards_sample_data_logs", + "default_timefield": "timestamp", + "isModelInvalid": false, + "drop_last_bucket": 0, + "data_source_id": "a" + }, + "withDataSourceFieldEmpty": { + "id": "61ca57f0-469d-11e7-af02-69e470af7417", + "type": "timeseries", + "series": [ + { + "id": "61ca57f1-469d-11e7-af02-69e470af7417", + "color": "#54B399", + "split_mode": "everything", + "split_color_mode": "opensearchDashboards", + "metrics": [ + { + "id": "61ca57f2-469d-11e7-af02-69e470af7417", + "type": "avg", + "field": "bytes" + } + ], + "separate_axis": 0, + "axis_position": "right", + "formatter": "number", + "chart_type": "line", + "line_width": 1, + "point_size": 1, + "fill": "", + "stacked": "none", + "label": "", + "type": "timeseries" + } + ], + "time_field": "timestamp", + "index_pattern": "opensearch_dashboards_sample_data_logs", + "interval": "", + "axis_position": "left", + "axis_formatter": "number", + "axis_scale": "normal", + "show_legend": 1, + "show_grid": 1, + "tooltip_mode": "show_all", + "default_index_pattern": "opensearch_dashboards_sample_data_logs", + "default_timefield": "timestamp", + "isModelInvalid": false, + "drop_last_bucket": 0, + "data_source_id": "" + }, + "withNoDataSourceField": { + "id": "61ca57f0-469d-11e7-af02-69e470af7417", + "type": "timeseries", + "series": [ + { + "id": "61ca57f1-469d-11e7-af02-69e470af7417", + "color": "#54B399", + "split_mode": "everything", + "split_color_mode": "opensearchDashboards", + "metrics": [ + { + "id": "61ca57f2-469d-11e7-af02-69e470af7417", + "type": "avg", + "field": "bytes" + } + ], + "separate_axis": 0, + "axis_position": "right", + "formatter": "number", + "chart_type": "line", + "line_width": 1, + "point_size": 1, + "fill": "", + "stacked": "none", + "label": "", + "type": "timeseries" + } + ], + "time_field": "timestamp", + "index_pattern": "opensearch_dashboards_sample_data_logs", + "interval": "", + "axis_position": "left", + "axis_formatter": "number", + "axis_scale": "normal", + "show_legend": 1, + "show_grid": 1, + "tooltip_mode": "show_all", + "default_index_pattern": "opensearch_dashboards_sample_data_logs", + "default_timefield": "timestamp", + "isModelInvalid": false, + "drop_last_bucket": 0 + }, + "referenceWithValidDataSource": [ + { + "id": "a", + "name": "dataSource", + "type": "data-source" + }, + { + "id": "some-dashboard-id", + "name": "some-dashboard", + "type": "dashboard" + } + ], + "referenceWithOutdatedDataSource": [ + { + "id": "b", + "name": "dataSource", + "type": "data-source" + }, + { + "id": "some-dashboard-id", + "name": "some-dashboard", + "type": "dashboard" + } + ] +} \ No newline at end of file diff --git a/src/plugins/vis_type_timeseries/server/lib/timeseries_visualization_client_wrapper.test.ts b/src/plugins/vis_type_timeseries/server/lib/timeseries_visualization_client_wrapper.test.ts new file mode 100644 index 000000000000..d992d52ca0d7 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/timeseries_visualization_client_wrapper.test.ts @@ -0,0 +1,193 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import _ from 'lodash'; + +import { SavedObjectReference, SavedObjectsClientWrapperOptions } from '../../../../core/server'; +import testParams from './test_utils/test_params.json'; +import { timeSeriesVisualizationClientWrapper } from './timeseries_visualization_client_wrapper'; +import { savedObjectsClientMock } from '../../../../core/server/mocks'; + +jest.mock('./services', () => ({ + getDataSourceEnabled: jest + .fn() + .mockReturnValueOnce({ enabled: false }) + .mockReturnValue({ enabled: true }), +})); + +describe('timeseriesVisualizationClientWrapper()', () => { + const client = savedObjectsClientMock.create(); + client.get = jest.fn().mockImplementation((type: string, id: string) => { + if (type === 'data-source' && id === 'non-existent-id') { + return Promise.resolve({ + id, + errors: {}, + }); + } + return Promise.resolve({ + id, + attributes: { + title: `${id} DataSource`, + }, + }); + }); + const mockedWrapperOptions = {} as SavedObjectsClientWrapperOptions; + mockedWrapperOptions.client = client; + + const getAttributesWithParams = (params: any) => { + return { + title: 'Some TSVB Visualization', + visState: JSON.stringify({ + title: 'Some TSVB Visualization', + type: 'metrics', + aggs: [], + params, + }), + }; + }; + + beforeEach(() => { + client.create.mockClear(); + }); + + const testClientCreate = ( + attributes: any, + references: SavedObjectReference[], + savedObjectType = 'visualization' + ) => { + expect(client.create).toBeCalledWith( + savedObjectType, + attributes, + expect.objectContaining({ references: expect.arrayContaining(references) }) + ); + }; + + test('if MDS is disabled, do not update the datasource references', async () => { + const attributes = getAttributesWithParams(testParams.withDataSourceFieldEmpty); + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references: [] }); + + testClientCreate(attributes, []); + }); + + test('non-visualization saved object should pass through', async () => { + const attributes = { + title: 'some-dashboard', + }; + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('dashboard', attributes, { references: [] }); + + testClientCreate(attributes, [], 'dashboard'); + }); + + test('non-metrics saved object should pass through', async () => { + const attributes = { + title: 'some-other-visualization', + visState: JSON.stringify({ + title: 'Some other visualization', + type: 'vega', + aggs: [], + params: {}, + }), + }; + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references: [] }); + + testClientCreate(attributes, []); + }); + + test('if a non-existent datasource id is in the params, remove all datasource references and the field name', async () => { + const params = _.clone(testParams.withDataSourceFieldNonEmpty); + params.data_source_id = 'non-existent-id'; + const references = [ + { + id: 'non-existent-id', + name: 'dataSource', + type: 'data-source', + }, + ]; + const attributes = getAttributesWithParams(params); + const newAttributes = getAttributesWithParams(testParams.withNoDataSourceField); + + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references }); + + testClientCreate(newAttributes, []); + }); + + test('if a datasource reference is empty and the data_source_id field is an empty string, do not change the object', async () => { + const attributes = getAttributesWithParams(testParams.withDataSourceFieldEmpty); + + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references: [] }); + + testClientCreate(attributes, []); + }); + + test('if a datasource reference is empty and the data_source_id field is not present, do not change the object', async () => { + const attributes = getAttributesWithParams(testParams.withNoDataSourceField); + + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references: [] }); + + testClientCreate(attributes, []); + }); + + test('if a datasource reference is outdated and the data_source_id field has an empty string, remove the datasource reference', async () => { + const attributes = getAttributesWithParams(testParams.withNoDataSourceField); + + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { + references: testParams.referenceWithOutdatedDataSource, + }); + + const newReferences = [{ id: 'some-dashboard-id', name: 'some-dashboard', type: 'dashboard' }]; + testClientCreate(attributes, newReferences); + }); + + test('if a datasource reference is outdated and the data_source_id field is not present, remove the datasource reference', async () => { + const attributes = getAttributesWithParams(testParams.withDataSourceFieldEmpty); + + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { + references: testParams.referenceWithOutdatedDataSource, + }); + + const newReferences = [{ id: 'some-dashboard-id', name: 'some-dashboard', type: 'dashboard' }]; + testClientCreate(attributes, newReferences); + }); + + test('if the datasource reference is empty and the data_source_id is present, add the datasource reference', async () => { + const attributes = getAttributesWithParams(testParams.withDataSourceFieldNonEmpty); + + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { + references: [{ id: 'some-dashboard-id', name: 'some-dashboard', type: 'dashboard' }], + }); + + testClientCreate(attributes, testParams.referenceWithValidDataSource); + }); + + test('if the datasource reference is different from the data_source_id, update the datasource reference', async () => { + const attributes = getAttributesWithParams(testParams.withDataSourceFieldNonEmpty); + + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { + references: testParams.referenceWithOutdatedDataSource, + }); + + testClientCreate(attributes, testParams.referenceWithValidDataSource); + }); + + test('if the datasource reference is identical to the data_source_id, do not change anything', async () => { + const attributes = getAttributesWithParams(testParams.withDataSourceFieldNonEmpty); + + const wrapper = timeSeriesVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { + references: testParams.referenceWithValidDataSource, + }); + + testClientCreate(attributes, testParams.referenceWithValidDataSource); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/lib/timeseries_visualization_client_wrapper.ts b/src/plugins/vis_type_timeseries/server/lib/timeseries_visualization_client_wrapper.ts new file mode 100644 index 000000000000..91f8fd5b4134 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/timeseries_visualization_client_wrapper.ts @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SavedObjectsClientContract, + SavedObjectsClientWrapperFactory, + SavedObjectsClientWrapperOptions, + SavedObjectsCreateOptions, + SavedObjectsErrorHelpers, +} from '../../../../core/server'; +import { getDataSourceEnabled } from './services'; + +export const TIMESERIES_VISUALIZATION_CLIENT_WRAPPER_ID = 'timeseries-visualization-client-wrapper'; +/** + * A lower priority number means that a wrapper will be first to execute. + * In this case, since this wrapper does not have any conflicts with other wrappers, it is set to 11. + * */ +export const TIMESERIES_VISUALIZATION_CLIENT_WRAPPER_PRIORITY = 11; + +export const timeSeriesVisualizationClientWrapper: SavedObjectsClientWrapperFactory = ( + wrapperOptions: SavedObjectsClientWrapperOptions +) => { + const createForTimeSeries = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + if (type !== 'visualization' || !getDataSourceEnabled().enabled) { + return await wrapperOptions.client.create(type, attributes, options); + } + + const tsvbAttributes = attributes as T & { visState: string }; + const visState = JSON.parse(tsvbAttributes.visState); + + if (visState.type !== 'metrics' || !visState.params) { + return await wrapperOptions.client.create(type, attributes, options); + } + + const newReferences = options?.references?.filter( + (reference) => reference.type !== 'data-source' + ); + + if (visState.params.data_source_id) { + try { + if (await checkIfDataSourceExists(visState.params.data_source_id, wrapperOptions.client)) { + newReferences?.push({ + id: visState.params.data_source_id, + name: 'dataSource', + type: 'data-source', + }); + } else { + delete visState.params.data_source_id; + } + } catch (err) { + const errMsg = `Failed to fetch existing data source for dataSourceId [${visState.params.data_source_id}]`; + throw SavedObjectsErrorHelpers.decorateBadRequestError(err, errMsg); + } + } + + tsvbAttributes.visState = JSON.stringify(visState); + + return await wrapperOptions.client.create(type, attributes, { + ...options, + references: newReferences, + }); + }; + + return { + ...wrapperOptions.client, + create: createForTimeSeries, + bulkCreate: wrapperOptions.client.bulkCreate, + checkConflicts: wrapperOptions.client.checkConflicts, + delete: wrapperOptions.client.delete, + find: wrapperOptions.client.find, + bulkGet: wrapperOptions.client.bulkGet, + get: wrapperOptions.client.get, + update: wrapperOptions.client.update, + bulkUpdate: wrapperOptions.client.bulkUpdate, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + }; +}; + +const checkIfDataSourceExists = async ( + id: string, + client: SavedObjectsClientContract +): Promise => { + return client.get('data-source', id).then((response) => !!response.attributes); +}; diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index af95e2e6b5dc..5bcf6278d85d 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -51,6 +51,12 @@ import { visDataRoutes } from './routes/vis'; import { fieldsRoutes } from './routes/fields'; import { SearchStrategyRegistry } from './lib/search_strategies'; import { uiSettings } from './ui_settings'; +import { setDataSourceEnabled } from './lib/services'; +import { + TIMESERIES_VISUALIZATION_CLIENT_WRAPPER_ID, + timeSeriesVisualizationClientWrapper, + TIMESERIES_VISUALIZATION_CLIENT_WRAPPER_PRIORITY, +} from './lib/timeseries_visualization_client_wrapper'; export interface LegacySetup { server: Server; @@ -115,6 +121,13 @@ export class VisTypeTimeseriesPlugin implements Plugin { searchStrategyRegistry, }; + setDataSourceEnabled({ enabled: !!plugins.dataSource }); + core.savedObjects.addClientWrapper( + TIMESERIES_VISUALIZATION_CLIENT_WRAPPER_PRIORITY, + TIMESERIES_VISUALIZATION_CLIENT_WRAPPER_ID, + timeSeriesVisualizationClientWrapper + ); + (async () => { const validationTelemetry = await this.validationTelementryService.setup(core, { ...plugins,