From 3eab14f8620a974edc56a5ccb0f2ed860749bc54 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 7 May 2024 17:50:36 -0700 Subject: [PATCH 1/9] [Multiple Datasource Test]add more test for icon and aggregated view (#6729) (#6740) * add more test for icon and aggregated view * Changeset file for PR #6729 created/updated * Update CHANGELOG.md --------- (cherry picked from commit 60f9d0522394a3d89bdf2f69bdb04bb9a14e9c4e) Signed-off-by: yujin-emma Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/6729.yml | 2 + .../__snapshots__/empty_icon.test.tsx.snap | 39 + .../__snapshots__/error_icon.test.tsx.snap | 24 + .../custom_database_icon/empty_icon.test.tsx | 15 + .../custom_database_icon/error_icon.test.tsx | 15 + .../data_source_aggregated_view.test.tsx.snap | 950 ++++++++++++++++++ .../data_source_aggregated_view.test.tsx | 233 +++++ 7 files changed, 1278 insertions(+) create mode 100644 changelogs/fragments/6729.yml create mode 100644 src/plugins/data_source_management/public/components/custom_database_icon/__snapshots__/empty_icon.test.tsx.snap create mode 100644 src/plugins/data_source_management/public/components/custom_database_icon/__snapshots__/error_icon.test.tsx.snap create mode 100644 src/plugins/data_source_management/public/components/custom_database_icon/empty_icon.test.tsx create mode 100644 src/plugins/data_source_management/public/components/custom_database_icon/error_icon.test.tsx diff --git a/changelogs/fragments/6729.yml b/changelogs/fragments/6729.yml new file mode 100644 index 000000000000..8a564e684ffc --- /dev/null +++ b/changelogs/fragments/6729.yml @@ -0,0 +1,2 @@ +fix: +- Add more test for icon and aggregated view ([#6729](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6729)) \ No newline at end of file diff --git a/src/plugins/data_source_management/public/components/custom_database_icon/__snapshots__/empty_icon.test.tsx.snap b/src/plugins/data_source_management/public/components/custom_database_icon/__snapshots__/empty_icon.test.tsx.snap new file mode 100644 index 000000000000..28f9cf9d4c68 --- /dev/null +++ b/src/plugins/data_source_management/public/components/custom_database_icon/__snapshots__/empty_icon.test.tsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Test on empty icon should render the component normally 1`] = ` + + + + + + + + +`; diff --git a/src/plugins/data_source_management/public/components/custom_database_icon/__snapshots__/error_icon.test.tsx.snap b/src/plugins/data_source_management/public/components/custom_database_icon/__snapshots__/error_icon.test.tsx.snap new file mode 100644 index 000000000000..fcb1050538a8 --- /dev/null +++ b/src/plugins/data_source_management/public/components/custom_database_icon/__snapshots__/error_icon.test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Test on empty icon should render the component normally 1`] = ` + + + + +`; diff --git a/src/plugins/data_source_management/public/components/custom_database_icon/empty_icon.test.tsx b/src/plugins/data_source_management/public/components/custom_database_icon/empty_icon.test.tsx new file mode 100644 index 000000000000..e72c429ec391 --- /dev/null +++ b/src/plugins/data_source_management/public/components/custom_database_icon/empty_icon.test.tsx @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { EmptyIcon } from './empty_icon'; + +describe('Test on empty icon', () => { + it('should render the component normally', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_source_management/public/components/custom_database_icon/error_icon.test.tsx b/src/plugins/data_source_management/public/components/custom_database_icon/error_icon.test.tsx new file mode 100644 index 000000000000..bba81a16f071 --- /dev/null +++ b/src/plugins/data_source_management/public/components/custom_database_icon/error_icon.test.tsx @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { ErrorIcon } from './error_icon'; + +describe('Test on empty icon', () => { + it('should render the component normally', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap index f6324c17061d..23fee253ce08 100644 --- a/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap @@ -1,5 +1,955 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`DataSourceAggregatedView empty state test due to filter out with local cluster hiding should render warning when no data sources added 1`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView empty state test due to filter out with local cluster hiding should render warning when no data sources added 2`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView empty state test with local cluster hiding should render warning when no data sources added 1`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView empty state test with local cluster hiding should render warning when no data sources added 2`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView empty state test with local cluster hiding should render warning when no data sources added 3`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView empty state test with local cluster hiding should render warning when no data sources added 4`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView error state test no matter hide local cluster or not should render error state when catch error 1`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView error state test no matter hide local cluster or not should render error state when catch error 2`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView error state test no matter hide local cluster or not should render error state when catch error 3`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + + + + +`; + +exports[`DataSourceAggregatedView error state test no matter hide local cluster or not should render error state when catch error 4`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + + + + +`; + exports[`DataSourceAggregatedView: read active view (displayAllCompatibleDataSources is set to false) should render normally with local cluster and active selections configured 1`] = ` { + let component: ShallowWrapper, React.Component<{}, {}, any>>; + let client: SavedObjectsClientContract; + const { toasts } = notificationServiceMock.createStartContract(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const application = applicationServiceMock.createStartContract(); + const nextTick = () => new Promise((res) => process.nextTick(res)); + + beforeEach(() => { + client = { + find: jest.fn().mockResolvedValue([]), + } as any; + mockResponseForSavedObjectsCalls(client, 'find', {}); + mockUiSettingsCalls(uiSettings, 'get', 'test1'); + jest.spyOn(utils, 'getApplication').mockReturnValue(application); + }); + + afterEach(() => { + jest.clearAllMocks(); // Clear all mocks to reset call counts and mock implementations + }); + + it.each([ + { + filter: (ds: SavedObject) => { + return true; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: true, + }, + { + filter: (ds: SavedObject) => { + return false; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: true, + }, + { + filter: (ds: SavedObject) => { + return true; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: false, + }, + { + filter: (ds: SavedObject) => { + return false; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: false, + }, + ])( + 'should render warning when no data sources added', + async ({ filter, activeDataSourceIds, hideLocalCluster, displayAllCompatibleDataSources }) => { + component = shallow( + + ); + + expect(component).toMatchSnapshot(); + await nextTick(); + expect(toasts.add).toHaveBeenCalledTimes(1); + expect(toasts.add.mock.calls[0][0]).toEqual({ + color: 'warning', + text: expect.any(Function), + title: 'No data sources connected yet. Connect your data sources to get started.', + }); + expect(component.state('showEmptyState')).toBe(true); + await nextTick(); + expect(component.find('NoDataSource').exists()).toBe(true); + } + ); +}); + +describe('DataSourceAggregatedView empty state test due to filter out with local cluster hiding', () => { + let component: ShallowWrapper, React.Component<{}, {}, any>>; + let client: SavedObjectsClientContract; + const { toasts } = notificationServiceMock.createStartContract(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const application = applicationServiceMock.createStartContract(); + const nextTick = () => new Promise((res) => process.nextTick(res)); + + beforeEach(() => { + client = { + find: jest.fn().mockResolvedValue([]), + } as any; + mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); + mockUiSettingsCalls(uiSettings, 'get', 'test1'); + jest.spyOn(utils, 'getApplication').mockReturnValue(application); + }); + + afterEach(() => { + jest.clearAllMocks(); // Clear all mocks to reset call counts and mock implementations + }); + + it.each([ + { + filter: (ds: SavedObject) => { + return false; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: true, + }, + { + filter: (ds: SavedObject) => { + return false; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: false, + }, + ])( + 'should render warning when no data sources added', + async ({ filter, activeDataSourceIds, hideLocalCluster, displayAllCompatibleDataSources }) => { + component = shallow( + + ); + + expect(component).toMatchSnapshot(); + await nextTick(); + expect(toasts.add).toHaveBeenCalledTimes(1); + expect(toasts.add.mock.calls[0][0]).toEqual({ + color: 'warning', + text: expect.any(Function), + title: 'No data sources connected yet. Connect your data sources to get started.', + }); + expect(component.state('showEmptyState')).toBe(true); + await nextTick(); + expect(component.find('NoDataSource').exists()).toBe(true); + } + ); +}); + +describe('DataSourceAggregatedView error state test no matter hide local cluster or not', () => { + let component: ShallowWrapper, React.Component<{}, {}, any>>; + let client: SavedObjectsClientContract; + const { toasts } = notificationServiceMock.createStartContract(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const application = applicationServiceMock.createStartContract(); + const nextTick = () => new Promise((res) => process.nextTick(res)); + + beforeEach(() => { + client = { + find: jest.fn().mockResolvedValue([]), + } as any; + mockErrorResponseForSavedObjectsCalls(client, 'find'); + mockUiSettingsCalls(uiSettings, 'get', 'test1'); + jest.spyOn(utils, 'getApplication').mockReturnValue(application); + }); + + afterEach(() => { + jest.clearAllMocks(); // Clear all mocks to reset call counts and mock implementations + }); + + it.each([ + { + filter: (ds: SavedObject) => { + return true; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: true, + }, + { + filter: (ds: SavedObject) => { + return false; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: true, + }, + { + filter: (ds: SavedObject) => { + return true; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: false, + }, + { + filter: (ds: SavedObject) => { + return false; + }, + activeDataSourceIds: undefined, + hideLocalCluster: true, + displayAllCompatibleDataSources: false, + }, + ])( + 'should render error state when catch error', + async ({ filter, activeDataSourceIds, hideLocalCluster, displayAllCompatibleDataSources }) => { + component = shallow( + + ); + + expect(component).toMatchSnapshot(); + await nextTick(); + expect(toasts.add).toBeCalled(); + expect(component.state('showError')).toBe(true); + } + ); +}); From c67e336d38d06638c36cc1cad4dd6c29553fb5c0 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 7 May 2024 19:03:56 -0700 Subject: [PATCH 2/9] [OSD Availability] Prevent OSD process crashes when disk is full (#6733) (#6743) * prevent crash when disk full * change verbose to false * add changeset file * update changeset contexts * change feature flag name --------- (cherry picked from commit 45c7c15757beec2bc7b54bf26837d0e1d9418036) Signed-off-by: Flyingliuhub <33105471+flyingliuhub@users.noreply.github.com> Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: ZilongX <99905560+ZilongX@users.noreply.github.com> --- changelogs/fragments/6733.yml | 2 + config/opensearch_dashboards.yml | 5 + .../bin/opensearch-dashboards-docker | 1 + src/legacy/server/config/schema.js | 1 + src/legacy/server/logging/configuration.js | 1 + src/legacy/server/logging/log_reporter.js | 26 ++- .../server/logging/log_reporter.test.js | 148 ++++++++++++++++++ 7 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 changelogs/fragments/6733.yml create mode 100644 src/legacy/server/logging/log_reporter.test.js diff --git a/changelogs/fragments/6733.yml b/changelogs/fragments/6733.yml new file mode 100644 index 000000000000..2021bce6c9dc --- /dev/null +++ b/changelogs/fragments/6733.yml @@ -0,0 +1,2 @@ +fix: +- [OSD Availability] Prevent OSD process crashes when disk is full ([#6733](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6733)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 47755ee5be7d..64ee515c23d3 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -135,6 +135,11 @@ # Enables you to specify a file where OpenSearch Dashboards stores log output. #logging.dest: stdout +# This configuration option controls the handling of error messages in the logging stream. It is disabled by default. +# When set to true, the 'ENOSPC' error message will not cause the OpenSearch Dashboards process to crash. Otherwise, +# the original behavior will be maintained. +#logging.ignoreEnospcError: false + # Set the value of this setting to true to suppress all logging output. #logging.silent: false diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/opensearch-dashboards-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/opensearch-dashboards-docker index 124a5e074842..1755ada630f2 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/opensearch-dashboards-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/opensearch-dashboards-docker @@ -58,6 +58,7 @@ opensearch_dashboards_vars=( opensearchDashboards.defaultAppId opensearchDashboards.index logging.dest + logging.ignoreEnospcError logging.json logging.quiet logging.rotate.enabled diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index f109c6058662..4c8d5c2bce6c 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -109,6 +109,7 @@ export default () => }), events: Joi.any().default({}), dest: Joi.string().default('stdout'), + ignoreEnospcError: Joi.boolean().default(false), filter: Joi.any().default({}), json: Joi.boolean().when('dest', { is: 'stdout', diff --git a/src/legacy/server/logging/configuration.js b/src/legacy/server/logging/configuration.js index 93103b3e5067..e942af2b9352 100644 --- a/src/legacy/server/logging/configuration.js +++ b/src/legacy/server/logging/configuration.js @@ -64,6 +64,7 @@ export default function loggingConfiguration(config) { json: config.get('logging.json'), dest: config.get('logging.dest'), timezone: config.get('logging.timezone'), + ignoreEnospcError: config.get('logging.ignoreEnospcError'), // I'm adding the default here because if you add another filter // using the commandline it will remove authorization. I want users diff --git a/src/legacy/server/logging/log_reporter.js b/src/legacy/server/logging/log_reporter.js index 228c83129802..b8e39304a7b6 100644 --- a/src/legacy/server/logging/log_reporter.js +++ b/src/legacy/server/logging/log_reporter.js @@ -30,6 +30,7 @@ import { Squeeze } from '@hapi/good-squeeze'; import { createWriteStream as writeStr } from 'fs'; +import { pipeline } from 'stream'; import LogFormatJson from './log_format_json'; import LogFormatString from './log_format_string'; @@ -51,18 +52,33 @@ export function getLoggerStream({ events, config }) { let dest; if (config.dest === 'stdout') { dest = process.stdout; + logInterceptor.pipe(squeeze).pipe(format).pipe(dest); } else { dest = writeStr(config.dest, { flags: 'a', encoding: 'utf8', }); - logInterceptor.on('end', () => { - dest.end(); - }); + if (config.ignoreEnospcError) { + pipeline(logInterceptor, squeeze, format, dest, onFinished); + } else { + logInterceptor.on('end', () => { + dest.end(); + }); + logInterceptor.pipe(squeeze).pipe(format).pipe(dest); + } } - logInterceptor.pipe(squeeze).pipe(format).pipe(dest); - return logInterceptor; } + +export function onFinished(error) { + if (error) { + if (error.code === 'ENOSPC') { + // eslint-disable-next-line no-console + console.error('Error in logging pipeline:', error.stack); + } else { + throw error; + } + } +} diff --git a/src/legacy/server/logging/log_reporter.test.js b/src/legacy/server/logging/log_reporter.test.js new file mode 100644 index 000000000000..babe5b7e6858 --- /dev/null +++ b/src/legacy/server/logging/log_reporter.test.js @@ -0,0 +1,148 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import stripAnsi from 'strip-ansi'; +import { getLoggerStream, onFinished } from './log_reporter'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('getLoggerStream', () => { + it('should log to stdout when the json config is set to false', async () => { + const lines = []; + const origWrite = process.stdout.write; + process.stdout.write = (buffer) => { + lines.push(stripAnsi(buffer.toString()).trim()); + return true; + }; + + const loggerStream = getLoggerStream({ + config: { + json: false, + dest: 'stdout', + filter: {}, + }, + events: { log: '*' }, + }); + + loggerStream.end({ event: 'log', tags: ['foo'], data: 'test data' }); + + await sleep(500); + + process.stdout.write = origWrite; + expect(lines.length).toBe(1); + expect(lines[0]).toMatch(/^log \[[^\]]*\] \[foo\] test data$/); + }); + + it('should log to stdout when the json config is set to true', async () => { + const lines = []; + const origWrite = process.stdout.write; + process.stdout.write = (buffer) => { + lines.push(JSON.parse(buffer.toString().trim())); + return true; + }; + + const loggerStream = getLoggerStream({ + config: { + json: true, + dest: 'stdout', + filter: {}, + }, + events: { log: '*' }, + }); + + loggerStream.end({ event: 'log', tags: ['foo'], data: 'test data' }); + + await sleep(500); + + process.stdout.write = origWrite; + expect(lines.length).toBe(1); + expect(lines[0]).toMatchObject({ + type: 'log', + tags: ['foo'], + message: 'test data', + }); + }); + + it('should log to custom file when the json config is set to false', async () => { + const dir = os.tmpdir(); + const logfile = `dest-${Date.now()}.log`; + const dest = path.join(dir, logfile); + + const loggerStream = getLoggerStream({ + config: { + json: false, + dest, + filter: {}, + }, + events: { log: '*' }, + }); + + loggerStream.end({ event: 'log', tags: ['foo'], data: 'test data' }); + + await sleep(500); + + const lines = stripAnsi(fs.readFileSync(dest, { encoding: 'utf8' })) + .trim() + .split(os.EOL); + expect(lines.length).toBe(1); + expect(lines[0]).toMatch(/^log \[[^\]]*\] \[foo\] test data$/); + }); + + it('should log to custom file when the json config is set to true and ignoreEnospcError', async () => { + const dir = os.tmpdir(); + const logfile = `dest-${Date.now()}.log`; + const dest = path.join(dir, logfile); + + const loggerStream = getLoggerStream({ + config: { + json: true, + dest, + ignoreEnospcError: true, + filter: {}, + }, + events: { log: '*' }, + }); + + loggerStream.end({ event: 'log', tags: ['foo'], data: 'test data' }); + + await sleep(500); + + const lines = fs + .readFileSync(dest, { encoding: 'utf8' }) + .trim() + .split(os.EOL) + .map((data) => JSON.parse(data)); + expect(lines.length).toBe(1); + expect(lines[0]).toMatchObject({ + type: 'log', + tags: ['foo'], + message: 'test data', + }); + }); + + it('should handle ENOSPC error when disk full', () => { + const error = { code: 'ENOSPC', stack: 'Error stack trace' }; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + onFinished(error); + }).not.toThrow(); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error in logging pipeline:', 'Error stack trace'); + + consoleErrorSpy.mockRestore(); + }); + + it('should throw error for non-ENOSPC error', () => { + const error = { message: 'non-ENOSPC error', code: 'OTHER', stack: 'Error stack trace' }; + + expect(() => { + onFinished(error); + }).toThrowError('non-ENOSPC error'); + }); +}); From af0bb9ee1d73169546c78f4fa974a72da700faf7 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Thu, 9 May 2024 20:22:38 -0700 Subject: [PATCH 3/9] [Multiple Datasource Test] Add test for edit data source form (#6742) (#6758) * add test for edit data source form * Changeset file for PR #6742 created/updated --------- (cherry picked from commit 884dbb31f9836adc63dfa20337c6e5afbc70eb35) Signed-off-by: yujin-emma Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/6742.yml | 2 + .../edit_form/edit_data_source_form.test.tsx | 194 ++++++++++++++++++ .../edit_form/edit_data_source_form.tsx | 3 + .../update_aws_credential_modal.test.tsx | 67 ++++++ .../data_source_management/public/mocks.ts | 15 ++ 5 files changed, 281 insertions(+) create mode 100644 changelogs/fragments/6742.yml create mode 100644 src/plugins/data_source_management/public/components/edit_data_source/components/update_aws_credential_modal/update_aws_credential_modal.test.tsx diff --git a/changelogs/fragments/6742.yml b/changelogs/fragments/6742.yml new file mode 100644 index 000000000000..390210dff311 --- /dev/null +++ b/changelogs/fragments/6742.yml @@ -0,0 +1,2 @@ +fix: +- Add test for edit data source form ([#6742](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6742)) \ No newline at end of file diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx index be5a3a31be73..876e36bf56ff 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx @@ -12,6 +12,7 @@ import { existingDatasourceNamesList, mockDataSourceAttributesWithNoAuth, mockDataSourceAttributesWithRegisteredAuth, + mockDataSourceAttributesWithSigV4Auth, } from '../../../../mocks'; import { OpenSearchDashboardsContextProvider } from '../../../../../../opensearch_dashboards_react/public'; import { EditDataSourceForm } from './edit_data_source_form'; @@ -34,6 +35,13 @@ const usernameFieldIdentifier = 'datasourceUsername'; const usernameFormRowIdentifier = '[data-test-subj="editDatasourceUsernameFormRow"]'; const passwordFieldIdentifier = '[data-test-subj="updateDataSourceFormPasswordField"]'; const updatePasswordBtnIdentifier = '[data-test-subj="editDatasourceUpdatePasswordBtn"]'; +const updateAwsCredsBtnIdentifier = '[data-test-subj="editDatasourceUpdateAwsCredentialBtn"]'; +const regionFieldIdentifier = 'dataSourceRegion'; +const accessKeyFieldIdentifier = 'dataSourceAccessKey'; +const accessKeyFormRowIdentifier = '[data-test-subj="editDataSourceFormAccessKeyField"]'; +const secretKeyFieldIdentifier = 'dataSourceSecretKey'; +const secretKeyFormRowIdentifier = '[data-test-subj="editDataSourceFormSecretKeyField"]'; + describe('Datasource Management: Edit Datasource Form', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); mockedContext.authenticationMethodRegistry.registerAuthenticationMethod( @@ -351,6 +359,192 @@ describe('Datasource Management: Edit Datasource Form', () => { expect(mockFn).toHaveBeenCalled(); }); }); + + describe('Case 3: With AWSsigv4', () => { + beforeEach(() => { + component = mount( + wrapWithIntl( + + ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + component.update(); + }); + + test('should render normally', () => { + // @ts-ignore + expect(component.find({ name: titleFieldIdentifier }).first().props().value).toBe( + mockDataSourceAttributesWithSigV4Auth.title + ); + expect(component.find(endpointFieldIdentifier).first().props().disabled).toBe(true); + }); + + /* Validation */ + test('should validate title as required field & no duplicates allowed', () => { + /* Validate empty title - required */ + updateInputFieldAndBlur(component, titleFieldIdentifier, ''); + // @ts-ignore + expect(component.find(titleFormRowIdentifier).first().props().isInvalid).toBe(true); + + /* Validate duplicate title */ + updateInputFieldAndBlur(component, titleFieldIdentifier, 'DuP20'); + // @ts-ignore + expect(component.find(titleFormRowIdentifier).first().props().isInvalid).toBe(true); + + /* change to original title */ + updateInputFieldAndBlur( + component, + titleFieldIdentifier, + mockDataSourceAttributesWithSigV4Auth.title + ); + // @ts-ignore + expect(component.find(titleFormRowIdentifier).first().props().isInvalid).toBe(false); + + /* change to valid updated title */ + updateInputFieldAndBlur(component, titleFieldIdentifier, 'test007'); + // @ts-ignore + expect(component.find(titleFormRowIdentifier).first().props().isInvalid).toBe(false); + }); + test('should validate access key as required field', () => { + /* Validate empty accessKey - required */ + updateInputFieldAndBlur(component, accessKeyFieldIdentifier, ''); + // @ts-ignore + expect(component.find(accessKeyFormRowIdentifier).first().props().isInvalid).toBe(true); + + /* change to original accessKey */ + updateInputFieldAndBlur( + component, + accessKeyFieldIdentifier, + mockDataSourceAttributesWithSigV4Auth.auth.credentials.accessKey + ); + // @ts-ignore + expect(component.find(accessKeyFormRowIdentifier).first().props().isInvalid).toBe(false); + /* change to valid updated accessKey */ + updateInputFieldAndBlur(component, accessKeyFieldIdentifier, 'test123'); + // @ts-ignore + expect(component.find(accessKeyFormRowIdentifier).first().props().isInvalid).toBe(false); + }); + test('should validate secret key as required field', () => { + /* Validate empty secretKey - required */ + updateInputFieldAndBlur(component, secretKeyFieldIdentifier, ''); + // @ts-ignore + expect(component.find(secretKeyFormRowIdentifier).first().props().isInvalid).toBe(true); + + /* change to original secretKey */ + updateInputFieldAndBlur( + component, + secretKeyFieldIdentifier, + mockDataSourceAttributesWithSigV4Auth.auth.credentials.secretKey + ); + // @ts-ignore + expect(component.find(secretKeyFormRowIdentifier).first().props().isInvalid).toBe(false); + /* change to valid updated secretKey */ + updateInputFieldAndBlur(component, secretKeyFieldIdentifier, 'test123'); + // @ts-ignore + expect(component.find(secretKeyFormRowIdentifier).first().props().isInvalid).toBe(false); + }); + /* Functionality */ + test('should display update aws credential modal on update button click and should update the credentials', () => { + act(() => { + component.find(updateAwsCredsBtnIdentifier).first().simulate('click'); + }); + component.update(); + expect(component.find('UpdateAwsCredentialModal').exists()).toBe(true); + + /* Update password */ + act(() => { + // @ts-ignore + component.find('UpdateAwsCredentialModal').prop('handleUpdateAwsCredential')('test123'); + }); + component.update(); + expect(mockFn).toHaveBeenCalled(); + expect(component.find('UpdateAwsCredentialModal').exists()).toBe(false); + }); + test("should hide username & password fields when 'AWS Sigv4' is selected as the credential type", () => { + setAuthTypeValue(authTypeSelectIdentifier, AuthType.SigV4); + component.update(); + expect(component.find(usernameFormRowIdentifier).exists()).toBe(false); + expect(component.find(passwordFieldIdentifier).exists()).toBe(false); + }); + + /* Cancel Changes */ + test('should reset form on click cancel changes', async () => { + await new Promise((resolve) => + setTimeout(() => { + updateInputFieldAndBlur(component, descriptionFieldIdentifier, ''); + expect( + // @ts-ignore + component.find(descriptionFormRowIdentifier).first().props().isInvalid + ).toBeUndefined(); + resolve(); + }, 100) + ); + await new Promise((resolve) => + setTimeout(() => { + /* Updated description*/ + updateInputFieldAndBlur(component, descriptionFieldIdentifier, 'testDescription'); + expect( + // @ts-ignore + component.find(descriptionFormRowIdentifier).first().props().isInvalid + ).toBeUndefined(); + + expect(component.find('[data-test-subj="datasource-edit-cancelButton"]').exists()).toBe( + true + ); + component + .find('[data-test-subj="datasource-edit-cancelButton"]') + .first() + .simulate('click'); + resolve(); + }, 100) + ); + }); + + /* Save Changes */ + test('should update the form with Username&Password on click save changes', async () => { + await new Promise((resolve) => + setTimeout(() => { + updateInputFieldAndBlur(component, descriptionFieldIdentifier, ''); + expect( + // @ts-ignore + component.find(descriptionFormRowIdentifier).first().props().isInvalid + ).toBeUndefined(); + resolve(); + }, 100) + ); + await new Promise((resolve) => + setTimeout(() => { + /* Updated description*/ + updateInputFieldAndBlur(component, descriptionFieldIdentifier, 'testDescription'); + expect( + // @ts-ignore + component.find(descriptionFormRowIdentifier).first().props().isInvalid + ).toBeUndefined(); + + expect(component.find('[data-test-subj="datasource-edit-saveButton"]').exists()).toBe( + true + ); + component.find('[data-test-subj="datasource-edit-saveButton"]').first().simulate('click'); + expect(mockFn).toHaveBeenCalled(); + resolve(); + }, 100) + ); + }); + }); }); describe('With Registered Authentication', () => { diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx index 63336cca5d32..e227e5e2087c 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx @@ -882,6 +882,7 @@ export class EditDataSourceForm extends React.Component diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/update_aws_credential_modal/update_aws_credential_modal.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/update_aws_credential_modal/update_aws_credential_modal.test.tsx new file mode 100644 index 000000000000..e7c7f0209438 --- /dev/null +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/update_aws_credential_modal/update_aws_credential_modal.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { render } from '@testing-library/react'; +import { UpdateAwsCredentialModal } from './update_aws_credential_modal'; +import { SigV4ServiceName } from '../../../../../../data_source/common/data_sources'; +import { EuiFormRow, EuiModalHeaderTitle } from '@elastic/eui'; +import { FormattedMessage } from 'react-intl'; + +describe('UpdateAwsCredentialModal', () => { + const mockHandleUpdateAwsCredential = jest.fn(); + const mockCloseUpdateAwsCredentialModal = jest.fn(); + + const props = { + region: 'us-east-1', + service: SigV4ServiceName.OpenSearch, + handleUpdateAwsCredential: mockHandleUpdateAwsCredential, + closeUpdateAwsCredentialModal: mockCloseUpdateAwsCredentialModal, + }; + + it('updates new access key state on input change', () => { + const wrapper = shallow(); + const newAccessKeyInput = wrapper.find('[name="updatedAccessKey"]'); + newAccessKeyInput.simulate('change', { target: { value: 'new_access_key' } }); + expect(wrapper.find('[name="updatedAccessKey"]').prop('value')).toEqual('new_access_key'); + }); + + it('renders modal with correct header title', () => { + const wrapper = shallow(); + const headerTitle = wrapper.find(EuiModalHeaderTitle).props().children; + expect(headerTitle).toEqual( +

+ +

+ ); + }); + + it('renders modal with correct label for updated secret key', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFormRow).at(4).props().label).toEqual('Updated secret key'); + }); + + it('renders modal with correct label for updated access key', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFormRow).at(3).props().label).toEqual('Updated access key'); + }); + + it('renders modal with correct region', () => { + const container = render(); + expect(container.getByTestId('data-source-update-credential-region')).toBeVisible(); + const text = container.getByTestId('data-source-update-credential-region'); + expect(text.textContent).toBe(props.region); + }); + + it('renders modal with service name select', () => { + const container = render(); + expect(container.getByTestId('data-source-update-credential-service-name')).toBeVisible(); + }); +}); diff --git a/src/plugins/data_source_management/public/mocks.ts b/src/plugins/data_source_management/public/mocks.ts index 3d991bd3e10a..0e5ec60bc307 100644 --- a/src/plugins/data_source_management/public/mocks.ts +++ b/src/plugins/data_source_management/public/mocks.ts @@ -267,6 +267,21 @@ export const mockDataSourceAttributesWithAuth = { }, }; +export const mockDataSourceAttributesWithSigV4Auth = { + id: 'test', + title: 'create-test-ds', + description: 'jest testing', + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'test123', + secretKey: 'test123', + region: 'us-east-1', + }, + }, +}; + export const mockDataSourceAttributesWithNoAuth = { id: 'test123', title: 'create-test-ds123', From 31694dbdabffec4a711678841f8080a06c4f879e Mon Sep 17 00:00:00 2001 From: "Qingyang(Abby) Hu" Date: Thu, 9 May 2024 21:21:46 -0700 Subject: [PATCH 4/9] [MQL] support enhancing language selector (#6613) (#6760) Enable with `data.enhancements.enabled: true` Allows for enhancing the data plugin UI service and search service. * Address issue with time range being invalid if previous state successfully queried and set it with a time range format that is invalid for the new query language * For example, DQL with quick time range (4 weeks to now), get results. Switch to PPL, even though PPL has a default time range enhancement. The props date range saved in the app state takes priority and sets the time range to quick range causing an error. I can still modify the time range and get a successful query but it will first fail until the user updates it to a non quick time range. * Add tests * Disable for plugins that do not support the functionality * By default index patterns are created with a unique ID. However, it can be enabled to create an index pattern with a custom ID that matches the name of the index pattern (which in turn maps to indices). * For seamless integration, the temp data frame would need to check if the index pattern that maps to the data frame name. And get it's id. * This means that dashboards with visualizations that were created with an index pattern unique ID still require the existing index pattern to exist in memory. closes #6639 closes #6311 partially resolves: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5504 * add error data frame move language to left, some styling and disable per app name --------- Signed-off-by: Kawika Avilla Signed-off-by: Paul Sebastian Co-authored-by: Kawika Avilla Co-authored-by: Paul Sebastian Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/6613.yml | 2 + config/opensearch_dashboards.yml | 5 +- package.json | 1 + packages/opensearch-datemath/index.d.ts | 2 + packages/opensearch-datemath/index.js | 4 +- packages/opensearch-datemath/index.test.ts | 44 +- .../src/cli_commands/snapshot.js | 7 + packages/osd-opensearch/src/cluster.js | 25 +- src/cli/serve/serve.js | 1 + .../dashboard_listing.test.tsx.snap | 140 ++++++ .../dashboard_top_nav.test.tsx.snap | 170 ++++++- src/plugins/data/common/constants.ts | 2 + .../data/common/data_frames/_df_cache.ts | 29 ++ .../data/common/data_frames/fields/index.ts | 6 + .../data/common/data_frames/fields/types.ts | 18 + src/plugins/data/common/data_frames/index.ts | 7 + src/plugins/data/common/data_frames/types.ts | 103 ++++ src/plugins/data/common/data_frames/utils.ts | 453 ++++++++++++++++++ src/plugins/data/common/index.ts | 1 + .../common/index_patterns/errors/index.ts | 1 + .../errors/missing_index_pattern.ts | 11 + .../common/index_patterns/fields/index.ts | 2 +- .../fields/index_pattern_field.ts | 5 +- .../common/index_patterns/fields/utils.ts | 15 +- .../index_patterns/_pattern_cache.ts | 4 + .../index_patterns/index_patterns.ts | 33 +- .../build_opensearch_query.ts | 11 + .../data/common/osd_field_types/index.ts | 2 + .../common/osd_field_types/osd_field_types.ts | 9 + .../common/search/opensearch_search/types.ts | 1 + .../create_search_source.test.ts | 5 + .../search_source/fetch/get_search_params.ts | 40 +- .../search/search_source/fetch/index.ts | 7 +- .../data/common/search/search_source/mocks.ts | 9 + .../search_source/search_source.test.ts | 5 + .../search/search_source/search_source.ts | 84 +++- .../data/common/search/search_source/types.ts | 3 +- src/plugins/data/common/search/types.ts | 5 +- src/plugins/data/common/types.ts | 19 + src/plugins/data/config.ts | 3 + .../public/data_sources/datasource/index.ts | 1 + .../datasource_service.test.ts | 22 +- .../data_sources/datasource_services/mocks.ts | 19 + .../register_default_datasource.ts | 2 + src/plugins/data/public/index.ts | 12 +- src/plugins/data/public/mocks.ts | 10 +- src/plugins/data/public/plugin.ts | 25 +- .../query_string/query_string_manager.ts | 6 +- src/plugins/data/public/search/mocks.ts | 7 + .../data/public/search/search_service.ts | 43 +- src/plugins/data/public/search/types.ts | 4 + src/plugins/data/public/services.ts | 2 + src/plugins/data/public/types.ts | 20 +- src/plugins/data/public/ui/index.ts | 1 + src/plugins/data/public/ui/mocks.ts | 46 ++ .../public/ui/query_string_input/_index.scss | 1 + .../_language_switcher.scss | 8 + .../language_switcher.test.tsx | 47 +- .../query_string_input/language_switcher.tsx | 182 ++++--- .../legacy_language_switcher.test.tsx | 55 +++ .../legacy_language_switcher.tsx | 118 +++++ .../query_bar_top_row.test.tsx | 2 + .../query_string_input/query_bar_top_row.tsx | 82 +++- .../query_string_input.test.tsx | 18 +- .../query_string_input/query_string_input.tsx | 72 ++- .../ui/search_bar/create_search_bar.tsx | 18 +- .../data/public/ui/search_bar/search_bar.tsx | 15 +- src/plugins/data/public/ui/settings/index.ts | 6 + src/plugins/data/public/ui/settings/mocks.ts | 18 + .../data/public/ui/settings/settings.ts | 93 ++++ src/plugins/data/public/ui/types.ts | 65 +++ src/plugins/data/public/ui/ui_service.ts | 70 +++ src/plugins/data/server/index.ts | 7 +- .../get_default_search_params.ts | 4 + .../opensearch_search_strategy.ts | 8 +- .../data/server/search/routes/call_msearch.ts | 8 +- .../data/server/search/search_service.ts | 47 +- src/plugins/data/server/search/types.ts | 9 +- src/plugins/data/server/ui_settings.ts | 57 +++ .../data_grid/data_grid_table_columns.tsx | 4 +- .../sidebar/discover_field_data_frame.tsx | 44 ++ .../sidebar/discover_sidebar.test.tsx | 2 + .../components/sidebar/discover_sidebar.tsx | 35 +- .../lib/display_index_pattern_creation.tsx | 17 + .../application/helpers/get_data_set.ts | 25 + .../view_components/panel/index.tsx | 31 +- .../utils/update_search_source.ts | 21 +- .../view_components/utils/use_search.ts | 20 +- src/plugins/discover/public/plugin.ts | 9 +- .../services/sample_data/data_sets/index.ts | 1 + .../sample_data/lib/sample_dataset_schema.ts | 6 + .../data_model/opensearch_query_parser.ts | 2 +- 92 files changed, 2485 insertions(+), 256 deletions(-) create mode 100644 changelogs/fragments/6613.yml create mode 100644 src/plugins/data/common/data_frames/_df_cache.ts create mode 100644 src/plugins/data/common/data_frames/fields/index.ts create mode 100644 src/plugins/data/common/data_frames/fields/types.ts create mode 100644 src/plugins/data/common/data_frames/index.ts create mode 100644 src/plugins/data/common/data_frames/types.ts create mode 100644 src/plugins/data/common/data_frames/utils.ts create mode 100644 src/plugins/data/common/index_patterns/errors/missing_index_pattern.ts create mode 100644 src/plugins/data/public/data_sources/datasource_services/mocks.ts create mode 100644 src/plugins/data/public/ui/mocks.ts create mode 100644 src/plugins/data/public/ui/query_string_input/_language_switcher.scss create mode 100644 src/plugins/data/public/ui/query_string_input/legacy_language_switcher.test.tsx create mode 100644 src/plugins/data/public/ui/query_string_input/legacy_language_switcher.tsx create mode 100644 src/plugins/data/public/ui/settings/index.ts create mode 100644 src/plugins/data/public/ui/settings/mocks.ts create mode 100644 src/plugins/data/public/ui/settings/settings.ts create mode 100644 src/plugins/data/public/ui/types.ts create mode 100644 src/plugins/data/public/ui/ui_service.ts create mode 100644 src/plugins/discover/public/application/components/sidebar/discover_field_data_frame.tsx create mode 100644 src/plugins/discover/public/application/components/sidebar/lib/display_index_pattern_creation.tsx create mode 100644 src/plugins/discover/public/application/helpers/get_data_set.ts diff --git a/changelogs/fragments/6613.yml b/changelogs/fragments/6613.yml new file mode 100644 index 000000000000..49cd01f52fdd --- /dev/null +++ b/changelogs/fragments/6613.yml @@ -0,0 +1,2 @@ +feat: +- Support language selector from the data plugin ([#6613](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6613)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 64ee515c23d3..3e5359675f05 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -324,4 +324,7 @@ # Set the value to true to enable Ui Metric Collectors in Usage Collector # This publishes the Application Usage and UI Metrics into the saved object, which can be accessed by /api/stats?extended=true&legacy=true&exclude_usage=false -# usageCollection.uiMetric.enabled: false \ No newline at end of file +# usageCollection.uiMetric.enabled: false + +# Set the value to true to enable enhancements for the data plugin +# data.enhancements.enabled: false diff --git a/package.json b/package.json index af4e18cce3d3..8a1d33ee5d8f 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "@hapi/wreck": "^17.1.0", "@opensearch-project/opensearch": "^1.1.0", "@opensearch-project/opensearch-next": "npm:@opensearch-project/opensearch@^2.6.0", + "@opensearch/datemath": "5.0.3", "@osd/ace": "1.0.0", "@osd/analytics": "1.0.0", "@osd/apm-config-loader": "1.0.0", diff --git a/packages/opensearch-datemath/index.d.ts b/packages/opensearch-datemath/index.d.ts index d5a38f0176ea..0706d7d0dccf 100644 --- a/packages/opensearch-datemath/index.d.ts +++ b/packages/opensearch-datemath/index.d.ts @@ -43,6 +43,8 @@ declare const datemath: { unitsAsc: Unit[]; unitsDesc: Unit[]; + isDateTime(input: any): boolean; + /** * Parses a string into a moment object. The string can be something like "now - 15m". * @param options.forceNow If this optional parameter is supplied, "now" will be treated as this diff --git a/packages/opensearch-datemath/index.js b/packages/opensearch-datemath/index.js index 4367949d7cf0..ecbedf482922 100644 --- a/packages/opensearch-datemath/index.js +++ b/packages/opensearch-datemath/index.js @@ -49,6 +49,7 @@ const isDate = (d) => Object.prototype.toString.call(d) === '[object Date]'; const isValidDate = (d) => isDate(d) && !isNaN(d.valueOf()); +const isDateTime = (d, momentInstance = moment) => momentInstance.isMoment(d); /* * This is a simplified version of opensearch's date parser. * If you pass in a momentjs instance as the third parameter the calculation @@ -57,7 +58,7 @@ const isValidDate = (d) => isDate(d) && !isNaN(d.valueOf()); */ function parse(text, { roundUp = false, momentInstance = moment, forceNow } = {}) { if (!text) return undefined; - if (momentInstance.isMoment(text)) return text; + if (isDateTime(text, momentInstance)) return text; if (isDate(text)) return momentInstance(text); if (forceNow !== undefined && !isValidDate(forceNow)) { throw new Error('forceNow must be a valid Date'); @@ -164,6 +165,7 @@ function parseDateMath(mathString, time, roundUp) { module.exports = { parse: parse, + isDateTime: isDateTime, unitsMap: Object.freeze(unitsMap), units: Object.freeze(units), unitsAsc: Object.freeze(unitsAsc), diff --git a/packages/opensearch-datemath/index.test.ts b/packages/opensearch-datemath/index.test.ts index e293da72ac7f..fbd1973fa932 100644 --- a/packages/opensearch-datemath/index.test.ts +++ b/packages/opensearch-datemath/index.test.ts @@ -122,19 +122,19 @@ describe('dateMath', function () { }); it('should return a moment if passed a date', function () { - expect(dateMath.parse(date).format(format)).to.eql(mmnt.format(format)); + expect(dateMath.parse(date)!.format(format)).to.eql(mmnt.format(format)); }); it('should return a moment if passed an ISO8601 string', function () { - expect(dateMath.parse(string).format(format)).to.eql(mmnt.format(format)); + expect(dateMath.parse(string)!.format(format)).to.eql(mmnt.format(format)); }); it('should return the current time when parsing now', function () { - expect(dateMath.parse('now').format(format)).to.eql(now.format(format)); + expect(dateMath.parse('now')!.format(format)).to.eql(now.format(format)); }); it('should use the forceNow parameter when parsing now', function () { - expect(dateMath.parse('now', { forceNow: anchoredDate }).valueOf()).to.eql(unix); + expect(dateMath.parse('now', { forceNow: anchoredDate })!.valueOf()).to.eql(unix); }); }); @@ -158,17 +158,17 @@ describe('dateMath', function () { const thenEx = `${anchor}||-${len}${span}`; it('should return ' + len + span + ' ago', function () { - const parsed = dateMath.parse(nowEx).format(format); + const parsed = dateMath.parse(nowEx)!.format(format); expect(parsed).to.eql(now.subtract(len, span).format(format)); }); it('should return ' + len + span + ' before ' + anchor, function () { - const parsed = dateMath.parse(thenEx).format(format); + const parsed = dateMath.parse(thenEx)!.format(format); expect(parsed).to.eql(anchored.subtract(len, span).format(format)); }); it('should return ' + len + span + ' before forceNow', function () { - const parsed = dateMath.parse(nowEx, { forceNow: anchoredDate }).valueOf(); + const parsed = dateMath.parse(nowEx, { forceNow: anchoredDate })!.valueOf(); expect(parsed).to.eql(anchored.subtract(len, span).valueOf()); }); }); @@ -195,17 +195,17 @@ describe('dateMath', function () { const thenEx = `${anchor}||+${len}${span}`; it('should return ' + len + span + ' from now', function () { - expect(dateMath.parse(nowEx).format(format)).to.eql(now.add(len, span).format(format)); + expect(dateMath.parse(nowEx)!.format(format)).to.eql(now.add(len, span).format(format)); }); it('should return ' + len + span + ' after ' + anchor, function () { - expect(dateMath.parse(thenEx).format(format)).to.eql( + expect(dateMath.parse(thenEx)!.format(format)).to.eql( anchored.add(len, span).format(format) ); }); it('should return ' + len + span + ' after forceNow', function () { - expect(dateMath.parse(nowEx, { forceNow: anchoredDate }).valueOf()).to.eql( + expect(dateMath.parse(nowEx, { forceNow: anchoredDate })!.valueOf()).to.eql( anchored.add(len, span).valueOf() ); }); @@ -229,26 +229,26 @@ describe('dateMath', function () { spans.forEach((span) => { it(`should round now to the beginning of the ${span}`, function () { - expect(dateMath.parse('now/' + span).format(format)).to.eql( + expect(dateMath.parse('now/' + span)!.format(format)).to.eql( now.startOf(span).format(format) ); }); it(`should round now to the beginning of forceNow's ${span}`, function () { - expect(dateMath.parse('now/' + span, { forceNow: anchoredDate }).valueOf()).to.eql( + expect(dateMath.parse('now/' + span, { forceNow: anchoredDate })!.valueOf()).to.eql( anchored.startOf(span).valueOf() ); }); it(`should round now to the end of the ${span}`, function () { - expect(dateMath.parse('now/' + span, { roundUp: true }).format(format)).to.eql( + expect(dateMath.parse('now/' + span, { roundUp: true })!.format(format)).to.eql( now.endOf(span).format(format) ); }); it(`should round now to the end of forceNow's ${span}`, function () { expect( - dateMath.parse('now/' + span, { roundUp: true, forceNow: anchoredDate }).valueOf() + dateMath.parse('now/' + span, { roundUp: true, forceNow: anchoredDate })!.valueOf() ).to.eql(anchored.endOf(span).valueOf()); }); }); @@ -269,28 +269,28 @@ describe('dateMath', function () { }); it('should round to the nearest second with 0 value', function () { - const val = dateMath.parse('now-0s/s').format(format); + const val = dateMath.parse('now-0s/s')!.format(format); expect(val).to.eql(now.startOf('s').format(format)); }); it('should subtract 17s, rounded to the nearest second', function () { - const val = dateMath.parse('now-17s/s').format(format); + const val = dateMath.parse('now-17s/s')!.format(format); expect(val).to.eql(now.startOf('s').subtract(17, 's').format(format)); }); it('should add 555ms, rounded to the nearest millisecond', function () { - const val = dateMath.parse('now+555ms/ms').format(format); + const val = dateMath.parse('now+555ms/ms')!.format(format); expect(val).to.eql(now.add(555, 'ms').startOf('ms').format(format)); }); it('should subtract 555ms, rounded to the nearest second', function () { - const val = dateMath.parse('now-555ms/s').format(format); + const val = dateMath.parse('now-555ms/s')!.format(format); expect(val).to.eql(now.subtract(555, 'ms').startOf('s').format(format)); }); it('should round weeks to Sunday by default', function () { const val = dateMath.parse('now-1w/w'); - expect(val.isoWeekday()).to.eql(7); + expect(val!.isoWeekday()).to.eql(7); }); it('should round weeks based on the passed moment locale start of week setting', function () { @@ -300,7 +300,7 @@ describe('dateMath', function () { week: { dow: 2 }, }); const val = dateMath.parse('now-1w/w', { momentInstance: m }); - expect(val.isoWeekday()).to.eql(2); + expect(val!.isoWeekday()).to.eql(2); }); it('should round up weeks based on the passed moment locale start of week setting', function () { @@ -315,11 +315,11 @@ describe('dateMath', function () { }); // The end of the range (rounding up) should be the last day of the week (so one day before) // our start of the week, that's why 3 - 1 - expect(val.isoWeekday()).to.eql(3 - 1); + expect(val!.isoWeekday()).to.eql(3 - 1); }); it('should round relative to forceNow', function () { - const val = dateMath.parse('now-0s/s', { forceNow: anchoredDate }).valueOf(); + const val = dateMath.parse('now-0s/s', { forceNow: anchoredDate })!.valueOf(); expect(val).to.eql(anchored.startOf('s').valueOf()); }); diff --git a/packages/osd-opensearch/src/cli_commands/snapshot.js b/packages/osd-opensearch/src/cli_commands/snapshot.js index ff21dbe851c8..84d6acee104e 100644 --- a/packages/osd-opensearch/src/cli_commands/snapshot.js +++ b/packages/osd-opensearch/src/cli_commands/snapshot.js @@ -50,6 +50,7 @@ exports.help = (defaults = {}) => { --download-only Download the snapshot but don't actually start it --ssl Sets up SSL on OpenSearch --security Installs and sets up the OpenSearch Security plugin on the cluster + --sql Installs and sets up the required OpenSearch SQL/PPL plugins on the cluster --P OpenSearch plugin artifact URL to install it on the cluster. We can use the flag multiple times to install multiple plugins on the cluster snapshot. The argument value can be url to zip file, maven coordinates of the plugin or for local zip files, use file:. @@ -77,6 +78,8 @@ exports.run = async (defaults = {}) => { boolean: ['security'], + boolean: ['sql'], + default: defaults, }); @@ -98,6 +101,10 @@ exports.run = async (defaults = {}) => { await cluster.setupSecurity(installPath, options.version ?? defaults.version); } + if (options.sql) { + await cluster.setupSql(installPath, options.version ?? defaults.version); + } + options.bundledJDK = true; await cluster.run(installPath, options); diff --git a/packages/osd-opensearch/src/cluster.js b/packages/osd-opensearch/src/cluster.js index 455a1e5f919f..4b1c8b38259d 100644 --- a/packages/osd-opensearch/src/cluster.js +++ b/packages/osd-opensearch/src/cluster.js @@ -70,10 +70,11 @@ const first = (stream, map) => }); exports.Cluster = class Cluster { - constructor({ log = defaultLog, ssl = false, security = false } = {}) { + constructor({ log = defaultLog, ssl = false, security = false, sql = false } = {}) { this._log = log; this._ssl = ssl; this._security = security; + this._sql = sql; this._caCertPromise = ssl ? readFile(CA_CERT_PATH) : undefined; } @@ -224,6 +225,28 @@ exports.Cluster = class Cluster { } } + /** + * Setups cluster with SQL/PPL plugins + * + * @param {string} installPath + * @property {String} version - version of OpenSearch + */ + async setupSql(installPath, version) { + await this.installSqlPlugin(installPath, version, 'opensearch-sql'); + await this.installSqlPlugin(installPath, version, 'opensearch-observability'); + } + + async installSqlPlugin(installPath, version, id) { + this._log.info(`Setting up: ${id}`); + try { + const pluginUrl = generateEnginePluginUrl(version, id); + await this.installOpenSearchPlugins(installPath, pluginUrl); + this._log.info(`Completed setup: ${id}`); + } catch (ex) { + this._log.warning(`Failed to setup: ${id}`); + } + } + /** * Starts OpenSearch and returns resolved promise once started * diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index e5af131dde2c..d203481c60a9 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -250,6 +250,7 @@ export default function (program) { .option('--dev', 'Run the server with development mode defaults') .option('--ssl', 'Run the dev server using HTTPS') .option('--security', 'Run the dev server using security defaults') + .option('--sql', 'Run the dev server using SQL/PPL defaults') .option('--dist', 'Use production assets from osd/optimizer') .option( '--no-base-path', diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index db34c4f229bb..4be28c0c4d08 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -276,6 +276,24 @@ exports[`dashboard listing hideWriteControls 1`] = ` "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -457,6 +475,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -465,6 +484,12 @@ exports[`dashboard listing hideWriteControls 1`] = ` "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -475,6 +500,9 @@ exports[`dashboard listing hideWriteControls 1`] = ` "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -1421,6 +1449,24 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -1602,6 +1648,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -1610,6 +1657,12 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -1620,6 +1673,9 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -2627,6 +2683,24 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -2808,6 +2882,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -2816,6 +2891,12 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -2826,6 +2907,9 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -3833,6 +3917,24 @@ exports[`dashboard listing renders table rows 1`] = ` "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -4014,6 +4116,7 @@ exports[`dashboard listing renders table rows 1`] = ` }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -4022,6 +4125,12 @@ exports[`dashboard listing renders table rows 1`] = ` "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -4032,6 +4141,9 @@ exports[`dashboard listing renders table rows 1`] = ` "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -5039,6 +5151,24 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -5220,6 +5350,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -5228,6 +5359,12 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -5238,6 +5375,9 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { 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 3649379ec583..5f71c7d5d217 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 @@ -260,6 +260,24 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -349,6 +367,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -357,6 +376,12 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -367,6 +392,9 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -1231,6 +1259,24 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -1320,6 +1366,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -1328,6 +1375,12 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -1338,6 +1391,9 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -2202,6 +2258,24 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -2291,6 +2365,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -2299,6 +2374,12 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -2309,6 +2390,9 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -3173,6 +3257,24 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -3262,6 +3364,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -3270,6 +3373,12 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -3280,6 +3389,9 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -4144,6 +4256,24 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -4233,6 +4363,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -4241,6 +4372,12 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -4251,6 +4388,9 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -5115,6 +5255,24 @@ exports[`Dashboard top nav render with all components 1`] = ` "getValueSuggestions": [MockFunction], "hasQuerySuggestions": [MockFunction], }, + "dataSources": Object { + "dataSourceFactory": DataSourceFactory { + "dataSourceClasses": Object {}, + }, + "dataSourceService": DataSourceService { + "dataSourceFetchers": Object {}, + "dataSources": Object {}, + "dataSourcesSubject": BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, + }, "fieldFormats": Object { "deserialize": [MockFunction], "getByFieldType": [MockFunction], @@ -5204,6 +5362,7 @@ exports[`Dashboard top nav render with all components 1`] = ` }, }, "search": Object { + "__enhance": [MockFunction], "aggs": Object { "calculateAutoTimeExpression": [Function], "createAggConfigs": [MockFunction], @@ -5212,6 +5371,12 @@ exports[`Dashboard top nav render with all components 1`] = ` "getAll": [Function], }, }, + "df": Object { + "clear": [MockFunction], + "get": [MockFunction], + "set": [MockFunction], + }, + "getDefaultSearchInterceptor": [MockFunction], "search": [MockFunction], "searchSource": Object { "create": [MockFunction], @@ -5222,6 +5387,9 @@ exports[`Dashboard top nav render with all components 1`] = ` "ui": Object { "IndexPatternSelect": [MockFunction], "SearchBar": [MockFunction], + "Settings": undefined, + "isEnhancementsEnabled": false, + "queryEnhancements": Map {}, }, }, "docLinks": Object { @@ -5823,4 +5991,4 @@ exports[`Dashboard top nav render with all components 1`] = ` -`; \ No newline at end of file +`; diff --git a/src/plugins/data/common/constants.ts b/src/plugins/data/common/constants.ts index 43db1fe72b9e..27cfc64cf2f6 100644 --- a/src/plugins/data/common/constants.ts +++ b/src/plugins/data/common/constants.ts @@ -35,6 +35,7 @@ export const UI_SETTINGS = { DOC_HIGHLIGHT: 'doc_table:highlight', QUERY_STRING_OPTIONS: 'query:queryString:options', QUERY_ALLOW_LEADING_WILDCARDS: 'query:allowLeadingWildcards', + QUERY_DATA_SOURCE_READONLY: 'query:dataSourceReadOnly', SEARCH_QUERY_LANGUAGE: 'search:queryLanguage', SORT_OPTIONS: 'sort:options', COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: 'courier:ignoreFilterIfFieldNotInIndex', @@ -60,4 +61,5 @@ export const UI_SETTINGS = { INDEXPATTERN_PLACEHOLDER: 'indexPattern:placeholder', FILTERS_PINNED_BY_DEFAULT: 'filters:pinnedByDefault', FILTERS_EDITOR_SUGGEST_VALUES: 'filterEditor:suggestValues', + DATAFRAME_HYDRATION_STRATEGY: 'dataframe:hydrationStrategy', } as const; diff --git a/src/plugins/data/common/data_frames/_df_cache.ts b/src/plugins/data/common/data_frames/_df_cache.ts new file mode 100644 index 000000000000..177f26840874 --- /dev/null +++ b/src/plugins/data/common/data_frames/_df_cache.ts @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IDataFrame } from '..'; + +export interface DfCache { + get: () => IDataFrame | undefined; + set: (value: IDataFrame) => IDataFrame; + clear: () => void; +} + +export function createDataFrameCache(): DfCache { + let df: IDataFrame | undefined; + const cache: DfCache = { + get: () => { + return df; + }, + set: (prom: IDataFrame) => { + df = prom; + return prom; + }, + clear: () => { + df = undefined; + }, + }; + return cache; +} diff --git a/src/plugins/data/common/data_frames/fields/index.ts b/src/plugins/data/common/data_frames/fields/index.ts new file mode 100644 index 000000000000..9f269633f307 --- /dev/null +++ b/src/plugins/data/common/data_frames/fields/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './types'; diff --git a/src/plugins/data/common/data_frames/fields/types.ts b/src/plugins/data/common/data_frames/fields/types.ts new file mode 100644 index 000000000000..47144c0c0198 --- /dev/null +++ b/src/plugins/data/common/data_frames/fields/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface IFieldType { + name: string; + type: string; + values: any[]; + count?: number; + aggregatable?: boolean; + filterable?: boolean; + searchable?: boolean; + sortable?: boolean; + visualizable?: boolean; + displayName?: string; + format?: any; +} diff --git a/src/plugins/data/common/data_frames/index.ts b/src/plugins/data/common/data_frames/index.ts new file mode 100644 index 000000000000..8b6a31eaea68 --- /dev/null +++ b/src/plugins/data/common/data_frames/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './types'; +export * from './utils'; diff --git a/src/plugins/data/common/data_frames/types.ts b/src/plugins/data/common/data_frames/types.ts new file mode 100644 index 000000000000..f8dd04193cff --- /dev/null +++ b/src/plugins/data/common/data_frames/types.ts @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SearchResponse } from 'elasticsearch'; +import { IFieldType } from './fields'; + +export * from './_df_cache'; + +/** @public **/ +export enum DATA_FRAME_TYPES { + DEFAULT = 'data_frame', + POLLING = 'data_frame_polling', +} + +export interface DataFrameService { + get: () => IDataFrame | undefined; + set: (dataFrame: IDataFrame) => Promise; + clear: () => void; +} + +/** + * A data frame is a two-dimensional labeled data structure with columns of potentially different types. + */ +export interface IDataFrame { + type?: DATA_FRAME_TYPES.DEFAULT; + name?: string; + schema?: Array>; + meta?: Record; + fields: IFieldType[]; + size: number; +} + +/** + * An aggregation is a process where the values of multiple rows are grouped together to form a single summary value. + */ +export interface DataFrameAgg { + value: number; +} + +/** + * A bucket aggregation is a type of aggregation that creates buckets or sets of data. + */ +export interface DataFrameBucketAgg extends DataFrameAgg { + key: string; +} + +/** + * This configuration is used to define how the aggregation should be performed. + */ +export interface DataFrameAggConfig { + id: string; + type: string; + field?: string; + order?: Record; + size?: number; + date_histogram?: { + field: string; + fixed_interval?: string; + calendar_interval?: string; + time_zone: string; + min_doc_count: number; + }; + avg?: { + field: string; + }; + cardinality?: { + field: string; + }; + terms?: { + field: string; + size: number; + order: Record; + }; + aggs?: Record; +} + +export interface PartialDataFrame extends Omit { + fields: Array>; +} + +/** + * To be utilize with aggregations and will map to buckets + * Plugins can get the aggregated value by their own logic + * Setting to null will disable the aggregation if plugin wishes + * In future, if the plugin doesn't intentionally set the value to null, + * we can calculate the value based on the fields. + */ +// TODO: handle composite +export interface IDataFrameWithAggs extends IDataFrame { + aggs: Record; +} + +export interface IDataFrameResponse extends SearchResponse { + type: DATA_FRAME_TYPES; + body: IDataFrame | IDataFrameWithAggs | IDataFrameError; + took: number; +} + +export interface IDataFrameError { + error: Error; +} diff --git a/src/plugins/data/common/data_frames/utils.ts b/src/plugins/data/common/data_frames/utils.ts new file mode 100644 index 000000000000..c3c55c5f227c --- /dev/null +++ b/src/plugins/data/common/data_frames/utils.ts @@ -0,0 +1,453 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SearchResponse } from 'elasticsearch'; +import datemath from '@opensearch/datemath'; +import { + DATA_FRAME_TYPES, + DataFrameAggConfig, + DataFrameBucketAgg, + IDataFrame, + IDataFrameWithAggs, + IDataFrameResponse, + PartialDataFrame, +} from './types'; +import { IFieldType } from './fields'; +import { IndexPatternFieldMap, IndexPatternSpec } from '../index_patterns'; +import { IOpenSearchDashboardsSearchRequest } from '../search'; +import { GetAggTypeFn, GetDataFrameAggQsFn } from '../types'; + +/** + * Returns the raw data frame from the search request. + * + * @param searchRequest - search request object. + * @returns dataframe + */ +export const getRawDataFrame = (searchRequest: IOpenSearchDashboardsSearchRequest) => { + return searchRequest.params?.body?.df; +}; + +/** + * Returns the raw query string from the search request. + * Gets current state query if exists, otherwise gets the initial query. + * + * @param searchRequest - search request object + * @returns query string + */ +export const getRawQueryString = ( + searchRequest: IOpenSearchDashboardsSearchRequest +): string | undefined => { + return ( + searchRequest.params?.body?.query?.queries[1]?.query ?? + searchRequest.params?.body?.query?.queries[0]?.query + ); +}; + +/** + * Returns the raw aggregations from the search request. + * + * @param searchRequest - search request object + * @returns aggregations + */ +export const getRawAggs = (searchRequest: IOpenSearchDashboardsSearchRequest) => { + return searchRequest.params?.body?.aggs; +}; + +/** + * Returns the unique values for raw aggregations. This is used + * with `other-filter` aggregation. To get the values that were not + * included in the aggregation response prior to this request. + * + * @param rawAggs - raw aggregations object + * @returns object containing the field and its unique values + */ +export const getUniqueValuesForRawAggs = (rawAggs: Record) => { + const filters = rawAggs.filters?.filters?.['']?.bool?.must_not; + if (!filters || !Array.isArray(filters)) { + return null; + } + const values: unknown[] = []; + let field: string | undefined; + + filters.forEach((agg: any) => { + Object.values(agg).forEach((aggValue) => { + Object.entries(aggValue as Record).forEach(([key, value]) => { + field = key; + values.push(value); + }); + }); + }); + + return { field, values }; +}; + +/** + * Returns the aggregation configuration for raw aggregations. + * Aggregations are nested objects, so this function recursively + * builds an object that is easier to work with. + * + * @param rawAggs - raw aggregations object + * @returns aggregation configuration + */ +export const getAggConfigForRawAggs = (rawAggs: Record): DataFrameAggConfig | null => { + const aggConfig: DataFrameAggConfig = { id: '', type: '' }; + + Object.entries(rawAggs).forEach(([aggKey, agg]) => { + aggConfig.id = aggKey; + Object.entries(agg as Record).forEach(([name, value]) => { + if (name === 'aggs') { + aggConfig.aggs = {}; + Object.entries(value as Record).forEach(([subAggKey, subRawAgg]) => { + const subAgg = getAggConfigForRawAggs(subRawAgg as Record); + if (subAgg) { + aggConfig.aggs![subAgg.id] = { ...subAgg, id: subAggKey }; + } + }); + } else { + aggConfig.type = name; + Object.assign(aggConfig, { [name]: value }); + } + }); + }); + + return aggConfig; +}; + +/** + * Returns the aggregation configuration. + * + * @param searchRequest - search request object + * @param aggConfig - aggregation configuration object + * @param getAggTypeFn - function to get the aggregation type from the aggsService + * @returns aggregation configuration + */ +export const getAggConfig = ( + searchRequest: IOpenSearchDashboardsSearchRequest, + aggConfig: Partial = {}, + getAggTypeFn: GetAggTypeFn +): DataFrameAggConfig => { + const rawAggs = getRawAggs(searchRequest); + Object.entries(rawAggs).forEach(([aggKey, agg]) => { + aggConfig.id = aggKey; + Object.entries(agg as Record).forEach(([name, value]) => { + if (name === 'aggs' && value) { + aggConfig.aggs = {}; + Object.entries(value as Record).forEach(([subAggKey, subRawAgg]) => { + const subAgg = getAggConfigForRawAggs(subRawAgg as Record); + if (subAgg) { + aggConfig.aggs![subAgg.id] = { ...subAgg, id: subAggKey }; + } + }); + } else { + aggConfig.type = getAggTypeFn(name)?.type ?? name; + Object.assign(aggConfig, { [name]: value }); + } + }); + }); + + return aggConfig as DataFrameAggConfig; +}; + +/** + * Converts the data frame response to a search response. + * This function is used to convert the data frame response to a search response + * to be used by the rest of the application. + * + * @param response - data frame response object + * @returns converted search response + */ +export const convertResult = (response: IDataFrameResponse): SearchResponse => { + const body = response.body; + if (body.hasOwnProperty('error')) { + return response; + } + const data = body as IDataFrame; + const hits: any[] = []; + for (let index = 0; index < data.size; index++) { + const hit: { [key: string]: any } = {}; + data.fields.forEach((field) => { + hit[field.name] = field.values[index]; + }); + hits.push({ + _index: data.name, + _source: hit, + }); + } + const searchResponse: SearchResponse = { + took: response.took, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: 0, + max_score: 0, + hits, + }, + }; + + if (data.hasOwnProperty('aggs')) { + const dataWithAggs = data as IDataFrameWithAggs; + if (!dataWithAggs.aggs) { + // TODO: MQL best guess, get timestamp field and caculate it here + return searchResponse; + } + searchResponse.aggregations = Object.entries(dataWithAggs.aggs).reduce( + (acc: Record, [id, value]) => { + const aggConfig = dataWithAggs.meta?.aggs; + if (id === 'other-filter') { + const buckets = value as DataFrameBucketAgg[]; + buckets.forEach((bucket) => { + const bucketValue = bucket.value; + searchResponse.hits.total += bucketValue; + }); + acc[id] = { + buckets: [{ '': { doc_count: 0 } }], + }; + return acc; + } + if (aggConfig && aggConfig.type === 'buckets') { + const buckets = value as DataFrameBucketAgg[]; + acc[id] = { + buckets: buckets.map((bucket) => { + const bucketValue = bucket.value; + searchResponse.hits.total += bucketValue; + return { + key_as_string: bucket.key, + key: (aggConfig as DataFrameAggConfig).date_histogram + ? new Date(bucket.key).getTime() + : bucket.key, + doc_count: bucketValue, + }; + }), + }; + return acc; + } + acc[id] = Array.isArray(value) ? value[0] : value; + return acc; + }, + {} + ); + } + + return searchResponse; +}; + +/** + * Formats the field value. + * + * @param field - field object + * @param value - value to format + * @returns formatted value + */ +export const formatFieldValue = (field: IFieldType | Partial, value: any): any => { + return field.format && field.format.convert ? field.format.convert(value) : value; +}; + +/** + * Returns the field type. This function is used to determine the field type so that can + * be used by the rest of the application. The field type must map to a OsdFieldType + * to be used by the rest of the application. + * + * @param field - field object + * @returns field type + */ +export const getFieldType = (field: IFieldType | Partial): string | undefined => { + const fieldName = field.name?.toLowerCase(); + if (fieldName?.includes('date') || fieldName?.includes('timestamp')) { + return 'date'; + } + if (field.values?.some((value) => value instanceof Date || datemath.isDateTime(value))) { + return 'date'; + } + if (field.type === 'struct') { + return 'object'; + } + + return field.type; +}; + +/** + * Returns the time field. If there is an aggConfig then we do not have to guess. + * If there is no aggConfig then we will try to guess the time field. + * + * @param data - data frame object. + * @param aggConfig - aggregation configuration object. + * @returns time field. + */ +export const getTimeField = ( + data: IDataFrame, + aggConfig?: DataFrameAggConfig +): Partial | undefined => { + const fields = data.schema || data.fields; + return aggConfig && aggConfig.date_histogram && aggConfig.date_histogram.field + ? fields.find((field) => field.name === aggConfig?.date_histogram?.field) + : fields.find((field) => field.type === 'date'); +}; + +/** + * Checks if the value is a GeoPoint. Expects an object with lat and lon properties. + * + * @param value - value to check + * @returns True if the value is a GeoPoint, false otherwise + */ +export const isGeoPoint = (value: any): boolean => { + return ( + typeof value === 'object' && + value !== null && + 'lat' in value && + 'lon' in value && + typeof value.lat === 'number' && + typeof value.lon === 'number' + ); +}; + +/** + * Creates a data frame. + * + * @param partial - partial data frame object + * @returns data frame. + */ +export const createDataFrame = (partial: PartialDataFrame): IDataFrame | IDataFrameWithAggs => { + let size = 0; + const processField = (field: any) => { + field.type = getFieldType(field); + if (!field.values) { + field.values = new Array(size); + } else if (field.values.length > size) { + size = field.values.length; + } + return field as IFieldType; + }; + + const schema = partial.schema?.map(processField); + const fields = partial.fields?.map(processField); + + return { + ...partial, + schema, + fields, + size, + }; +}; + +/** + * Updates the data frame metadata. Metadata is used to store the aggregation configuration. + * It also stores the query string used to fetch the data frame aggregations. + * + * @param params - { dataFrame, qs, aggConfig, timeField, timeFilter, getAggQsFn } + */ +export const updateDataFrameMeta = ({ + dataFrame, + qs, + aggConfig, + timeField, + timeFilter, + getAggQsFn, +}: { + dataFrame: IDataFrame; + qs: string; + aggConfig: DataFrameAggConfig; + timeField: any; + timeFilter: string; + getAggQsFn: GetDataFrameAggQsFn; +}) => { + dataFrame.meta = { + aggs: aggConfig, + aggsQs: { + [aggConfig.id]: getAggQsFn({ + qs, + aggConfig, + timeField, + timeFilter, + }), + }, + }; + + if (aggConfig.aggs) { + const subAggs = aggConfig.aggs as Record; + for (const [key, subAgg] of Object.entries(subAggs)) { + const subAggConfig: Record = { [key]: subAgg }; + dataFrame.meta.aggsQs[subAgg.id] = getAggQsFn({ + qs, + aggConfig: subAggConfig as DataFrameAggConfig, + timeField, + timeFilter, + }); + } + } +}; + +/** + * Converts a data frame to index pattern spec which can be used to create an index pattern. + * + * @param dataFrame - data frame object + * @param id - index pattern id if it exists + * @returns index pattern spec + */ +export const dataFrameToSpec = (dataFrame: IDataFrame, id?: string): IndexPatternSpec => { + const fields = (dataFrame.schema || dataFrame.fields) as IFieldType[]; + + const toFieldSpec = (field: IFieldType, overrides: Partial) => { + return { + ...field, + ...overrides, + aggregatable: field.aggregatable ?? true, + searchable: field.searchable ?? true, + }; + }; + + const flattenFields = (acc: IndexPatternFieldMap, field: IFieldType): any => { + switch (field.type) { + case 'object': + const dataField = dataFrame.fields.find((f) => f.name === field.name) || field; + if (dataField) { + const subField = dataField.values[0]; + if (!subField) { + acc[field.name] = toFieldSpec(field, {}); + break; + } + Object.entries(subField).forEach(([key, value]) => { + const subFieldName = `${dataField.name}.${key}`; + const subFieldType = typeof value; + if (subFieldType === 'object' && isGeoPoint(value)) { + acc[subFieldName] = toFieldSpec(subField, { + name: subFieldName, + type: 'geo_point', + }); + } else { + acc = flattenFields(acc, { + name: subFieldName, + type: subFieldType, + values: + subFieldType === 'object' + ? Object.entries(value as Record)?.map(([k, v]) => ({ + name: `${subFieldName}.${k}`, + type: typeof v, + })) + : [], + } as IFieldType); + } + }); + } + break; + default: + acc[field.name] = toFieldSpec(field, {}); + break; + } + return acc; + }; + + return { + id: id ?? DATA_FRAME_TYPES.DEFAULT, + title: dataFrame.name, + timeFieldName: getTimeField(dataFrame)?.name, + type: !id ? DATA_FRAME_TYPES.DEFAULT : undefined, + fields: fields.reduce(flattenFields, {} as IndexPatternFieldMap), + }; +}; diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index 1eefb2383f8b..d7b7e56e2280 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -30,6 +30,7 @@ export * from './constants'; export * from './opensearch_query'; +export * from './data_frames'; export * from './field_formats'; export * from './field_mapping'; export * from './index_patterns'; diff --git a/src/plugins/data/common/index_patterns/errors/index.ts b/src/plugins/data/common/index_patterns/errors/index.ts index 9d2a1175dd3f..d77b7f140afe 100644 --- a/src/plugins/data/common/index_patterns/errors/index.ts +++ b/src/plugins/data/common/index_patterns/errors/index.ts @@ -29,3 +29,4 @@ */ export * from './duplicate_index_pattern'; +export * from './missing_index_pattern'; diff --git a/src/plugins/data/common/index_patterns/errors/missing_index_pattern.ts b/src/plugins/data/common/index_patterns/errors/missing_index_pattern.ts new file mode 100644 index 000000000000..7d5fa3356314 --- /dev/null +++ b/src/plugins/data/common/index_patterns/errors/missing_index_pattern.ts @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export class MissingIndexPatternError extends Error { + constructor(message: string) { + super(message); + this.name = 'MissingIndexPatternError'; + } +} diff --git a/src/plugins/data/common/index_patterns/fields/index.ts b/src/plugins/data/common/index_patterns/fields/index.ts index 351c0d3b7593..58a8612fd8c9 100644 --- a/src/plugins/data/common/index_patterns/fields/index.ts +++ b/src/plugins/data/common/index_patterns/fields/index.ts @@ -29,6 +29,6 @@ */ export * from './types'; -export { isFilterable, isNestedField } from './utils'; +export { isFilterable, isNestedField, setOverrides, getOverrides } from './utils'; export * from './field_list'; export * from './index_pattern_field'; diff --git a/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts b/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts index e1882abb9722..ce9dd5a67eba 100644 --- a/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts +++ b/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts @@ -28,7 +28,7 @@ * under the License. */ -import { OsdFieldType, getOsdFieldType } from '../../osd_field_types'; +import { OsdFieldType, getOsdFieldOverrides, getOsdFieldType } from '../../osd_field_types'; import { OSD_FIELD_TYPES } from '../../osd_field_types/types'; import { IFieldType } from './types'; import { FieldSpec, IndexPattern } from '../..'; @@ -133,6 +133,7 @@ export class IndexPatternField implements IFieldType { } public get filterable() { + if (getOsdFieldOverrides().filterable !== undefined) return !!getOsdFieldOverrides().filterable; return ( this.name === '_id' || this.scripted || @@ -141,6 +142,8 @@ export class IndexPatternField implements IFieldType { } public get visualizable() { + if (getOsdFieldOverrides().visualizable !== undefined) + return !!getOsdFieldOverrides().visualizable; const notVisualizableFieldTypes: string[] = [OSD_FIELD_TYPES.UNKNOWN, OSD_FIELD_TYPES.CONFLICT]; return this.aggregatable && !notVisualizableFieldTypes.includes(this.spec.type); } diff --git a/src/plugins/data/common/index_patterns/fields/utils.ts b/src/plugins/data/common/index_patterns/fields/utils.ts index 5178568533d2..1b96cd3b7063 100644 --- a/src/plugins/data/common/index_patterns/fields/utils.ts +++ b/src/plugins/data/common/index_patterns/fields/utils.ts @@ -28,12 +28,25 @@ * under the License. */ -import { getFilterableOsdTypeNames } from '../../osd_field_types'; +import { + getFilterableOsdTypeNames, + getOsdFieldOverrides, + setOsdFieldOverrides, +} from '../../osd_field_types'; import { IFieldType } from './types'; const filterableTypes = getFilterableOsdTypeNames(); +export function setOverrides(overrides: Record | undefined) { + setOsdFieldOverrides(overrides); +} + +export function getOverrides(): Record { + return getOsdFieldOverrides(); +} + export function isFilterable(field: IFieldType): boolean { + if (getOverrides().filterable !== undefined) return !!getOverrides().filterable; return ( field.name === '_id' || field.scripted || diff --git a/src/plugins/data/common/index_patterns/index_patterns/_pattern_cache.ts b/src/plugins/data/common/index_patterns/index_patterns/_pattern_cache.ts index cc54e7b4cdde..834d21e6c35d 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/_pattern_cache.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/_pattern_cache.ts @@ -32,6 +32,7 @@ import { IndexPattern } from './index_pattern'; export interface PatternCache { get: (id: string) => IndexPattern; + getByTitle: (title: string) => IndexPattern; set: (id: string, value: IndexPattern) => IndexPattern; clear: (id: string) => void; clearAll: () => void; @@ -43,6 +44,9 @@ export function createIndexPatternCache(): PatternCache { get: (id: string) => { return vals[id]; }, + getByTitle: (title: string) => { + return Object.values(vals).find((pattern: IndexPattern) => pattern.title === title); + }, set: (id: string, prom: any) => { vals[id] = prom; return prom; diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 688605821097..d60c1b6dd901 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -55,7 +55,7 @@ import { UI_SETTINGS, SavedObject } from '../../../common'; import { SavedObjectNotFound } from '../../../../opensearch_dashboards_utils/common'; import { IndexPatternMissingIndices } from '../lib'; import { findByTitle, getIndexPatternTitle } from '../utils'; -import { DuplicateIndexPatternError } from '../errors'; +import { DuplicateIndexPatternError, MissingIndexPatternError } from '../errors'; const indexPatternCache = createIndexPatternCache(); const MAX_ATTEMPTS_TO_RESOLVE_CONFLICTS = 3; @@ -192,8 +192,10 @@ export class IndexPatternsService { * Clear index pattern list cache * @param id optionally clear a single id */ - clearCache = (id?: string) => { - this.savedObjectsCache = null; + clearCache = (id?: string, clearSavedObjectsCache: boolean = true) => { + if (clearSavedObjectsCache) { + this.savedObjectsCache = null; + } if (id) { indexPatternCache.clear(id); } else { @@ -208,6 +210,10 @@ export class IndexPatternsService { return this.savedObjectsCache; }; + saveToCache = (id: string, indexPattern: IndexPattern) => { + indexPatternCache.set(id, indexPattern); + }; + /** * Get default index pattern */ @@ -282,9 +288,13 @@ export class IndexPatternsService { * Refresh field list for a given index pattern * @param indexPattern */ - refreshFields = async (indexPattern: IndexPattern) => { + refreshFields = async (indexPattern: IndexPattern, skipType = false) => { try { - const fields = await this.getFieldsForIndexPattern(indexPattern); + const indexPatternCopy = skipType + ? ({ ...indexPattern, type: undefined } as IndexPattern) + : indexPattern; + + const fields = await this.getFieldsForIndexPattern(indexPatternCopy); const scripted = indexPattern.getScriptedFields().map((field) => field.spec); indexPattern.fields.replaceAll([...fields, ...scripted]); } catch (err) { @@ -499,6 +509,19 @@ export class IndexPatternsService { return indexPattern; }; + /** + * Get an index pattern by title if cached + * @param id + */ + + getByTitle = (title: string, ignoreErrors: boolean = false): IndexPattern => { + const indexPattern = indexPatternCache.getByTitle(title); + if (!indexPattern && !ignoreErrors) { + throw new MissingIndexPatternError(`Missing index pattern: ${title}`); + } + return indexPattern; + }; + migrate(indexPattern: IndexPattern, newTitle: string) { return this.savedObjectsClient .update( diff --git a/src/plugins/data/common/opensearch_query/opensearch_query/build_opensearch_query.ts b/src/plugins/data/common/opensearch_query/opensearch_query/build_opensearch_query.ts index 481eae12d121..23295c3bafec 100644 --- a/src/plugins/data/common/opensearch_query/opensearch_query/build_opensearch_query.ts +++ b/src/plugins/data/common/opensearch_query/opensearch_query/build_opensearch_query.ts @@ -66,6 +66,17 @@ export function buildOpenSearchQuery( const validQueries = queries.filter((query) => has(query, 'query')); const queriesByLanguage = groupBy(validQueries, 'language'); + const unsupportedQueries = Object.keys(queriesByLanguage).filter( + (language) => language !== 'kuery' && language.toLowerCase() !== 'lucene' + ); + if (unsupportedQueries.length > 0) { + return { + type: 'unsupported', + queries, + filters, + }; + } + const kueryQuery = buildQueryFromKuery( indexPattern, queriesByLanguage.kuery, diff --git a/src/plugins/data/common/osd_field_types/index.ts b/src/plugins/data/common/osd_field_types/index.ts index 9454adc2d475..345a22094334 100644 --- a/src/plugins/data/common/osd_field_types/index.ts +++ b/src/plugins/data/common/osd_field_types/index.ts @@ -35,4 +35,6 @@ export { getOsdFieldType, getOsdTypeNames, getFilterableOsdTypeNames, + getOsdFieldOverrides, + setOsdFieldOverrides, } from './osd_field_types'; diff --git a/src/plugins/data/common/osd_field_types/osd_field_types.ts b/src/plugins/data/common/osd_field_types/osd_field_types.ts index b9fe14ff1a87..00fb67a8dc5e 100644 --- a/src/plugins/data/common/osd_field_types/osd_field_types.ts +++ b/src/plugins/data/common/osd_field_types/osd_field_types.ts @@ -34,6 +34,7 @@ import { OPENSEARCH_FIELD_TYPES, OSD_FIELD_TYPES } from './types'; /** @private */ const registeredOsdTypes = createOsdFieldTypes(); +let osdFieldOverrides = {}; /** * Get a type object by name @@ -75,3 +76,11 @@ export const castOpenSearchToOsdFieldTypeName = ( */ export const getFilterableOsdTypeNames = (): string[] => registeredOsdTypes.filter((type) => type.filterable).map((type) => type.name); + +export const setOsdFieldOverrides = (newOverrides: { [key: string]: any } | undefined) => { + osdFieldOverrides = newOverrides ? Object.assign({}, osdFieldOverrides, newOverrides) : {}; +}; + +export const getOsdFieldOverrides = (): { [key: string]: any } => { + return osdFieldOverrides; +}; diff --git a/src/plugins/data/common/search/opensearch_search/types.ts b/src/plugins/data/common/search/opensearch_search/types.ts index 3b93177bf201..f90a3f1de245 100644 --- a/src/plugins/data/common/search/opensearch_search/types.ts +++ b/src/plugins/data/common/search/opensearch_search/types.ts @@ -57,6 +57,7 @@ export type ISearchRequestParams> = { export interface IOpenSearchSearchRequest extends IOpenSearchDashboardsSearchRequest { indexType?: string; + language?: string; dataSourceId?: string; } diff --git a/src/plugins/data/common/search/search_source/create_search_source.test.ts b/src/plugins/data/common/search/search_source/create_search_source.test.ts index 467ecec59f5a..68dfa7699e13 100644 --- a/src/plugins/data/common/search/search_source/create_search_source.test.ts +++ b/src/plugins/data/common/search/search_source/create_search_source.test.ts @@ -50,6 +50,11 @@ describe('createSearchSource', () => { callMsearch: jest.fn(), loadingCount$: new BehaviorSubject(0), }, + df: { + get: jest.fn().mockReturnValue({}), + set: jest.fn().mockReturnValue({}), + clear: jest.fn(), + }, }; indexPatternContractMock = ({ diff --git a/src/plugins/data/common/search/search_source/fetch/get_search_params.ts b/src/plugins/data/common/search/search_source/fetch/get_search_params.ts index a25d6e530bad..d9bd7721d6cb 100644 --- a/src/plugins/data/common/search/search_source/fetch/get_search_params.ts +++ b/src/plugins/data/common/search/search_source/fetch/get_search_params.ts @@ -29,7 +29,7 @@ */ import { UI_SETTINGS } from '../../../constants'; -import { GetConfigFn } from '../../../types'; +import { GetConfigFn, GetDataFrameFn, DestroyDataFrameFn } from '../../../types'; import { ISearchRequestParams } from '../../index'; import { SearchRequest } from './types'; @@ -49,16 +49,50 @@ export function getPreference(getConfig: GetConfigFn) { : undefined; } +export function getExternalSearchParamsFromRequest( + searchRequest: SearchRequest, + dependencies: { + getConfig: GetConfigFn; + getDataFrame: GetDataFrameFn; + } +): ISearchRequestParams { + const { getConfig, getDataFrame } = dependencies; + const searchParams = getSearchParams(getConfig); + const dataFrame = getDataFrame(); + const indexTitle = searchRequest.index.title || searchRequest.index; + + return { + index: indexTitle, + body: { + ...searchRequest.body, + ...(dataFrame && dataFrame?.name === indexTitle ? { df: dataFrame } : {}), + }, + ...searchParams, + }; +} + /** @public */ // TODO: Could provide this on runtime contract with dependencies // already wired up. export function getSearchParamsFromRequest( searchRequest: SearchRequest, - dependencies: { getConfig: GetConfigFn } + dependencies: { + getConfig: GetConfigFn; + getDataFrame?: GetDataFrameFn; + destroyDataFrame?: DestroyDataFrameFn; + } ): ISearchRequestParams { - const { getConfig } = dependencies; + const { getConfig, getDataFrame, destroyDataFrame } = dependencies; const searchParams = getSearchParams(getConfig); + if (getDataFrame && destroyDataFrame) { + if (getDataFrame()) { + delete searchRequest.body.df; + delete searchRequest.indexType; + destroyDataFrame(); + } + } + return { index: searchRequest.index.title || searchRequest.index, body: searchRequest.body, diff --git a/src/plugins/data/common/search/search_source/fetch/index.ts b/src/plugins/data/common/search/search_source/fetch/index.ts index bb432ec0d833..f171ca38738e 100644 --- a/src/plugins/data/common/search/search_source/fetch/index.ts +++ b/src/plugins/data/common/search/search_source/fetch/index.ts @@ -28,6 +28,11 @@ * under the License. */ -export { getSearchParams, getSearchParamsFromRequest, getPreference } from './get_search_params'; +export { + getSearchParams, + getExternalSearchParamsFromRequest, + getSearchParamsFromRequest, + getPreference, +} from './get_search_params'; export { RequestFailure } from './request_error'; export * from './types'; diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index 959d1aebfe53..83ea582ef99f 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -47,6 +47,8 @@ export const searchSourceInstanceMock: MockedKeys = { createChild: jest.fn().mockReturnThis(), setParent: jest.fn(), getParent: jest.fn().mockReturnThis(), + setDataFrame: jest.fn(), + getDataFrame: jest.fn().mockReturnThis(), fetch: jest.fn().mockResolvedValue({}), onRequestStart: jest.fn(), getSearchRequestBody: jest.fn(), @@ -54,6 +56,8 @@ export const searchSourceInstanceMock: MockedKeys = { history: [], getSerializedFields: jest.fn(), serialize: jest.fn(), + flatten: jest.fn().mockReturnThis(), + destroyDataFrame: jest.fn(), }; export const searchSourceCommonMock: jest.Mocked = { @@ -70,4 +74,9 @@ export const createSearchSourceMock = (fields?: SearchSourceFields) => callMsearch: jest.fn(), loadingCount$: new BehaviorSubject(0), }, + df: { + get: jest.fn().mockReturnValue({}), + set: jest.fn().mockReturnValue({}), + clear: jest.fn(), + }, }); diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 92cc0682a136..09adc867d213 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -84,6 +84,11 @@ describe('SearchSource', () => { callMsearch: jest.fn(), loadingCount$: new BehaviorSubject(0), }, + df: { + get: jest.fn().mockReturnValue({}), + set: jest.fn().mockReturnValue({}), + clear: jest.fn(), + }, }; }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index abe6fa1b5cb4..b7dc48b404b6 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -87,10 +87,17 @@ import { normalizeSortRequest } from './normalize_sort_request'; import { filterDocvalueFields } from './filter_docvalue_fields'; import { fieldWildcardFilter } from '../../../../opensearch_dashboards_utils/common'; import { IIndexPattern } from '../../index_patterns'; +import { DATA_FRAME_TYPES, IDataFrame, IDataFrameResponse, convertResult } from '../../data_frames'; import { IOpenSearchSearchRequest, IOpenSearchSearchResponse, ISearchOptions } from '../..'; import { IOpenSearchDashboardsSearchRequest, IOpenSearchDashboardsSearchResponse } from '../types'; import { ISearchSource, SearchSourceOptions, SearchSourceFields } from './types'; -import { FetchHandlers, RequestFailure, getSearchParamsFromRequest, SearchRequest } from './fetch'; +import { + FetchHandlers, + RequestFailure, + getExternalSearchParamsFromRequest, + getSearchParamsFromRequest, + SearchRequest, +} from './fetch'; import { getOpenSearchQueryConfig, @@ -116,6 +123,7 @@ export const searchSourceRequiredUiSettings = [ UI_SETTINGS.QUERY_STRING_OPTIONS, UI_SETTINGS.SEARCH_INCLUDE_FROZEN, UI_SETTINGS.SORT_OPTIONS, + UI_SETTINGS.DATAFRAME_HYDRATION_STRATEGY, ]; export interface SearchSourceDependencies extends FetchHandlers { @@ -123,11 +131,18 @@ export interface SearchSourceDependencies extends FetchHandlers { // search options required here and returning a promise instead of observable. search: < SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest, - SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse + SearchStrategyResponse extends + | IOpenSearchDashboardsSearchResponse + | IDataFrameResponse = IOpenSearchSearchResponse >( request: SearchStrategyRequest, options: ISearchOptions ) => Promise; + df: { + get: () => IDataFrame | undefined; + set: (dataFrame: IDataFrame) => Promise; + clear: () => void; + }; } /** @public **/ @@ -267,6 +282,36 @@ export class SearchSource { return this.parent; } + /** + * Get the data frame of this SearchSource + * @return {undefined|IDataFrame} + */ + getDataFrame() { + return this.dependencies.df.get(); + } + + /** + * Set the data frame of this SearchSource + * + * @async + * @return {undefined|IDataFrame} + */ + async setDataFrame(dataFrame: IDataFrame | undefined) { + if (dataFrame) { + await this.dependencies.df.set(dataFrame); + } else { + this.destroyDataFrame(); + } + return this.getDataFrame(); + } + + /** + * Clear the data frame of this SearchSource + */ + destroyDataFrame() { + this.dependencies.df.clear(); + } + /** * Fetch this source and reject the returned Promise on error * @@ -282,6 +327,8 @@ export class SearchSource { let response; if (getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES)) { response = await this.legacyFetch(searchRequest, options); + } else if (this.isUnsupportedRequest(searchRequest)) { + response = await this.fetchExternalSearch(searchRequest, options); } else { const indexPattern = this.getField('index'); searchRequest.dataSourceId = indexPattern?.dataSourceRef?.id; @@ -337,12 +384,39 @@ export class SearchSource { const params = getSearchParamsFromRequest(searchRequest, { getConfig, + getDataFrame: this.getDataFrame.bind(this), + destroyDataFrame: this.destroyDataFrame.bind(this), }); return search( { params, indexType: searchRequest.indexType, dataSourceId: searchRequest.dataSourceId }, options - ).then(({ rawResponse }) => onResponse(searchRequest, rawResponse)); + ).then((response: any) => onResponse(searchRequest, response.rawResponse)); + } + + /** + * Run a non-native search using the search service + * @return {Promise>} + */ + private async fetchExternalSearch(searchRequest: SearchRequest, options: ISearchOptions) { + const { search, getConfig, onResponse } = this.dependencies; + + const params = getExternalSearchParamsFromRequest(searchRequest, { + getConfig, + getDataFrame: this.getDataFrame.bind(this), + }); + + return search({ params }, options).then(async (response: any) => { + if (response.hasOwnProperty('type')) { + if ((response as IDataFrameResponse).type === DATA_FRAME_TYPES.DEFAULT) { + const dataFrameResponse = response as IDataFrameResponse; + await this.setDataFrame(dataFrameResponse.body as IDataFrame); + return onResponse(searchRequest, convertResult(response as IDataFrameResponse)); + } + // TODO: MQL else if data_frame_polling then poll for the data frame updating the df fields only + } + return onResponse(searchRequest, response.rawResponse); + }); } /** @@ -366,6 +440,10 @@ export class SearchSource { ); } + private isUnsupportedRequest(request: SearchRequest): boolean { + return request.body!.query.hasOwnProperty('type') && request.body!.query.type === 'unsupported'; + } + /** * Called by requests of this search source when they are started * @param options diff --git a/src/plugins/data/common/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts index 13b100aab1a7..9f3fd75e1ce9 100644 --- a/src/plugins/data/common/search/search_source/types.ts +++ b/src/plugins/data/common/search/search_source/types.ts @@ -29,7 +29,7 @@ */ import { NameList } from 'elasticsearch'; -import { Filter, IndexPattern, Query } from '../..'; +import { Filter, IDataFrame, IndexPattern, Query } from '../..'; import { SearchSource } from './search_source'; /** @@ -103,6 +103,7 @@ export interface SearchSourceFields { searchAfter?: OpenSearchQuerySearchAfter; timeout?: string; terminate_after?: number; + df?: IDataFrame; } export interface SearchSourceOptions { diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index e05c0adb46f0..d2bcdc0f4d05 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -34,6 +34,7 @@ import { IOpenSearchSearchResponse, ISearchOptions, } from '../../common/search'; +import { IDataFrameResponse } from '../data_frames'; export type ISearch = ( request: IOpenSearchDashboardsSearchRequest, @@ -42,7 +43,9 @@ export type ISearch = ( export type ISearchGeneric = < SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest, - SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse + SearchStrategyResponse extends + | IOpenSearchDashboardsSearchResponse + | IDataFrameResponse = IOpenSearchSearchResponse >( request: SearchStrategyRequest, options?: ISearchOptions diff --git a/src/plugins/data/common/types.ts b/src/plugins/data/common/types.ts index 361ba39edfec..6a1f6e5a99d3 100644 --- a/src/plugins/data/common/types.ts +++ b/src/plugins/data/common/types.ts @@ -28,9 +28,13 @@ * under the License. */ +import { DataFrameAggConfig, IDataFrame } from './data_frames'; +import { BucketAggType, MetricAggType } from './search'; + export * from './query/types'; export * from './osd_field_types/types'; export * from './index_patterns/types'; +export * from './data_frames/types'; /** * If a service is being shared on both the client and the server, and @@ -43,3 +47,18 @@ export * from './index_patterns/types'; * not possible. */ export type GetConfigFn = (key: string, defaultOverride?: T) => T; +export type GetDataFrameFn = () => IDataFrame | undefined; +export type GetDataFrameAggQsFn = ({ + qs, + aggConfig, + timeField, + timeFilter, +}: { + qs: string; + aggConfig: DataFrameAggConfig; + timeField: any; + timeFilter: any; +}) => any; + +export type DestroyDataFrameFn = () => void; +export type GetAggTypeFn = (id: string) => BucketAggType | MetricAggType; diff --git a/src/plugins/data/config.ts b/src/plugins/data/config.ts index f8b553f3da1b..f8dcad85fb49 100644 --- a/src/plugins/data/config.ts +++ b/src/plugins/data/config.ts @@ -31,6 +31,9 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ + enhancements: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), autocomplete: schema.object({ querySuggestions: schema.object({ enabled: schema.boolean({ defaultValue: true }), diff --git a/src/plugins/data/public/data_sources/datasource/index.ts b/src/plugins/data/public/data_sources/datasource/index.ts index e45cd6dad22c..7cc4f8e6549a 100644 --- a/src/plugins/data/public/data_sources/datasource/index.ts +++ b/src/plugins/data/public/data_sources/datasource/index.ts @@ -12,5 +12,6 @@ export { IDataSourceQueryResult, DataSourceConnectionStatus, IndexPatternOption, + IDataSourceDataSet, } from './types'; export { DataSourceFactory } from './factory'; diff --git a/src/plugins/data/public/data_sources/datasource_services/datasource_service.test.ts b/src/plugins/data/public/data_sources/datasource_services/datasource_service.test.ts index e1d2077f60e5..1182ffe65d8a 100644 --- a/src/plugins/data/public/data_sources/datasource_services/datasource_service.test.ts +++ b/src/plugins/data/public/data_sources/datasource_services/datasource_service.test.ts @@ -5,7 +5,11 @@ import { waitFor } from '@testing-library/dom'; import { DataSource } from '../datasource'; -import { IndexPatternsService } from '../../index_patterns'; +import { SavedObject } from '../../../../../core/public'; +import { + IndexPatternSavedObjectAttrs, + IndexPatternsService, +} from '../../index_patterns/index_patterns'; import { DataSourceService } from '../datasource_services'; import { LocalDSDataSetParams, @@ -55,8 +59,20 @@ class MockDataSource extends DataSource< } async getDataSet(dataSetParams?: LocalDSDataSetParams): Promise { - await this.indexPattern.ensureDefaultIndexPattern(); - return await this.indexPattern.getCache(); + const savedObjectLst = await this.indexPattern.getCache(); + + if (!Array.isArray(savedObjectLst)) { + return { dataSets: [] }; + } + + return { + dataSets: savedObjectLst.map((savedObject: SavedObject) => { + return { + id: savedObject.id, + title: savedObject.attributes.title, + }; + }), + }; } async testConnection(): Promise { diff --git a/src/plugins/data/public/data_sources/datasource_services/mocks.ts b/src/plugins/data/public/data_sources/datasource_services/mocks.ts new file mode 100644 index 000000000000..1bf4d4d7a9a4 --- /dev/null +++ b/src/plugins/data/public/data_sources/datasource_services/mocks.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataSourceService } from './datasource_service'; +import { DataSourceStart } from './types'; +import { DataSourceFactory } from '../datasource'; + +function createStartContract(): jest.Mocked { + return { + dataSourceService: DataSourceService.getInstance(), + dataSourceFactory: DataSourceFactory.getInstance(), + }; +} + +export const dataSourceServiceMock = { + createStartContract, +}; diff --git a/src/plugins/data/public/data_sources/register_default_datasource.ts b/src/plugins/data/public/data_sources/register_default_datasource.ts index ce169955704b..d8ae19114c3c 100644 --- a/src/plugins/data/public/data_sources/register_default_datasource.ts +++ b/src/plugins/data/public/data_sources/register_default_datasource.ts @@ -39,3 +39,5 @@ export const registerDefaultDataSource = (data: Omit>; export type Start = jest.Mocked>; @@ -59,7 +61,7 @@ const createSetupContract = (): Setup => { }; }; -const createStartContract = (): Start => { +const createStartContract = (isEnhancementsEnabled: boolean = false): Start => { const queryStartMock = queryServiceMock.createStartContract(); return { actions: { @@ -70,10 +72,7 @@ const createStartContract = (): Start => { search: searchServiceMock.createStartContract(), fieldFormats: fieldFormatsServiceMock.createStartContract(), query: queryStartMock, - ui: { - IndexPatternSelect: jest.fn(), - SearchBar: jest.fn().mockReturnValue(null), - }, + ui: uiServiceMock.createStartContract(isEnhancementsEnabled), indexPatterns: ({ find: jest.fn((search) => [{ id: search, title: search }]), createField: jest.fn(() => {}), @@ -93,6 +92,7 @@ const createStartContract = (): Start => { ), clearCache: jest.fn(), } as unknown) as IndexPatternsContract, + dataSources: dataSourceServiceMock.createStartContract(), }; }; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 4917eb9db9e2..f22a61423d1d 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -46,9 +46,9 @@ import { } from './types'; import { AutocompleteService } from './autocomplete'; import { SearchService } from './search/search_service'; +import { UiService } from './ui/ui_service'; import { FieldFormatsService } from './field_formats'; import { QueryService } from './query'; -import { createIndexPatternSelect } from './ui/index_pattern_select'; import { IndexPatternsService, onRedirectNoIndexPattern, @@ -63,9 +63,9 @@ import { setOverlays, setQueryService, setSearchService, + setUiService, setUiSettings, } from './services'; -import { createSearchBar } from './ui/search_bar/create_search_bar'; import { opensearchaggs } from './search/expressions'; import { SELECT_RANGE_TRIGGER, @@ -112,12 +112,14 @@ export class DataPublicPlugin > { private readonly autocomplete: AutocompleteService; private readonly searchService: SearchService; + private readonly uiService: UiService; private readonly fieldFormatsService: FieldFormatsService; private readonly queryService: QueryService; private readonly storage: IStorageWrapper; constructor(initializerContext: PluginInitializerContext) { this.searchService = new SearchService(initializerContext); + this.uiService = new UiService(initializerContext); this.queryService = new QueryService(); this.fieldFormatsService = new FieldFormatsService(); this.autocomplete = new AutocompleteService(initializerContext); @@ -161,13 +163,17 @@ export class DataPublicPlugin expressions, }); + const uiService = this.uiService.setup(core, {}); + return { + // TODO: MQL autocomplete: this.autocomplete.setup(core), search: searchService, fieldFormats: this.fieldFormatsService.setup(core), query: queryService, __enhance: (enhancements: DataPublicPluginEnhancements) => { - searchService.__enhance(enhancements.search); + if (enhancements.search) searchService.__enhance(enhancements.search); + if (enhancements.ui) uiService.__enhance(enhancements.ui); }, }; } @@ -246,18 +252,12 @@ export class DataPublicPlugin registerDefaultDataSource(dataServices); - const SearchBar = createSearchBar({ - core, - data: dataServices, - storage: this.storage, - }); + const uiService = this.uiService.start(core, { dataServices, storage: this.storage }); + setUiService(uiService); return { ...dataServices, - ui: { - IndexPatternSelect: createIndexPatternSelect(core.savedObjects.client), - SearchBar, - }, + ui: uiService, }; } @@ -265,5 +265,6 @@ export class DataPublicPlugin this.autocomplete.clearProviders(); this.queryService.stop(); this.searchService.stop(); + this.uiService.stop(); } } diff --git a/src/plugins/data/public/query/query_string/query_string_manager.ts b/src/plugins/data/public/query/query_string/query_string_manager.ts index bee5d4c3ded9..3747cabaf9ca 100644 --- a/src/plugins/data/public/query/query_string/query_string_manager.ts +++ b/src/plugins/data/public/query/query_string/query_string_manager.ts @@ -44,6 +44,10 @@ export class QueryStringManager { this.query$ = new BehaviorSubject(this.getDefaultQuery()); } + private getDefaultQueryString() { + return this.storage.get('opensearchDashboards.userQueryString') || ''; + } + private getDefaultLanguage() { return ( this.storage.get('opensearchDashboards.userQueryLanguage') || @@ -53,7 +57,7 @@ export class QueryStringManager { public getDefaultQuery() { return { - query: '', + query: this.getDefaultQueryString(), language: this.getDefaultLanguage(), }; } diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 32c0ef61aca6..736de838df8d 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -45,6 +45,13 @@ function createStartContract(): jest.Mocked { search: jest.fn(), showError: jest.fn(), searchSource: searchSourceMock.createStartContract(), + __enhance: jest.fn(), + getDefaultSearchInterceptor: jest.fn(), + df: { + get: jest.fn().mockReturnValue({}), + set: jest.fn().mockReturnValue({}), + clear: jest.fn(), + }, }; } diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index c73e7881faa6..340c007963af 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -57,6 +57,13 @@ import { getShardDelayBucketAgg, } from '../../common/search/aggs/buckets/shard_delay'; import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn'; +import { + DataFrameService, + IDataFrame, + IDataFrameResponse, + createDataFrameCache, + dataFrameToSpec, +} from '../../common/data_frames'; /** @internal */ export interface SearchServiceSetupDependencies { @@ -73,7 +80,9 @@ export interface SearchServiceStartDependencies { export class SearchService implements Plugin { private readonly aggsService = new AggsService(); private readonly searchSourceService = new SearchSourceService(); + private readonly dfCache = createDataFrameCache(); private searchInterceptor!: ISearchInterceptor; + private defaultSearchInterceptor!: ISearchInterceptor; private usageCollector?: SearchUsageCollector; constructor(private initializerContext: PluginInitializerContext) {} @@ -95,6 +104,7 @@ export class SearchService implements Plugin { startServices: getStartServices(), usageCollector: this.usageCollector!, }); + this.defaultSearchInterceptor = this.searchInterceptor; expressions.registerFunction(opensearchdsl); expressions.registerType(opensearchRawResponse); @@ -129,11 +139,36 @@ export class SearchService implements Plugin { const loadingCount$ = new BehaviorSubject(0); http.addLoadingCountSource(loadingCount$); + const dfService: DataFrameService = { + get: () => this.dfCache.get(), + set: async (dataFrame: IDataFrame) => { + if (this.dfCache.get() && this.dfCache.get()?.name !== dataFrame.name) { + indexPatterns.clearCache(this.dfCache.get()!.name, false); + } + this.dfCache.set(dataFrame); + const existingIndexPattern = indexPatterns.getByTitle(dataFrame.name!, true); + const dataSet = await indexPatterns.create( + dataFrameToSpec(dataFrame, existingIndexPattern?.id), + !existingIndexPattern?.id + ); + // save to cache by title because the id is not unique for temporary index pattern created + indexPatterns.saveToCache(dataSet.title, dataSet); + }, + clear: () => { + if (this.dfCache.get() === undefined) return; + // name because the id is not unique for temporary index pattern created + indexPatterns.clearCache(this.dfCache.get()!.name, false); + this.dfCache.clear(); + }, + }; + const searchSourceDependencies: SearchSourceDependencies = { getConfig: uiSettings.get.bind(uiSettings), search: < SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest, - SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse + SearchStrategyResponse extends + | IOpenSearchDashboardsSearchResponse + | IDataFrameResponse = IOpenSearchSearchResponse >( request: SearchStrategyRequest, options: ISearchOptions @@ -145,6 +180,7 @@ export class SearchService implements Plugin { callMsearch: getCallMsearch({ http }), loadingCount$, }, + df: dfService, }; return { @@ -154,6 +190,11 @@ export class SearchService implements Plugin { this.searchInterceptor.showError(e); }, searchSource: this.searchSourceService.start(indexPatterns, searchSourceDependencies), + __enhance: (enhancements: SearchEnhancements) => { + this.searchInterceptor = enhancements.searchInterceptor; + }, + getDefaultSearchInterceptor: () => this.defaultSearchInterceptor, + df: dfService, }; } diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 8a0d82b855c8..29dc37b41c91 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -35,6 +35,7 @@ import { AggsSetup, AggsSetupDependencies, AggsStartDependencies, AggsStart } fr import { ISearchGeneric, ISearchStartSearchSource } from '../../common/search'; import { IndexPatternsContract } from '../../common/index_patterns/index_patterns'; import { UsageCollectionSetup } from '../../../usage_collection/public'; +import { DataFrameService } from '../../common/data_frames'; export { ISearchStartSearchSource }; @@ -78,6 +79,9 @@ export interface ISearchStart { * {@link ISearchStartSearchSource} */ searchSource: ISearchStartSearchSource; + __enhance: (enhancements: SearchEnhancements) => void; + getDefaultSearchInterceptor: () => ISearchInterceptor; + df: DataFrameService; } export { SEARCH_EVENT_TYPE } from './collectors'; diff --git a/src/plugins/data/public/services.ts b/src/plugins/data/public/services.ts index 3bcc9d69a9a4..d75dab2986ca 100644 --- a/src/plugins/data/public/services.ts +++ b/src/plugins/data/public/services.ts @@ -59,3 +59,5 @@ export const [getQueryService, setQueryService] = createGetterSetter< export const [getSearchService, setSearchService] = createGetterSetter< DataPublicPluginStart['search'] >('Search'); + +export const [getUiService, setUiService] = createGetterSetter('Ui'); diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index 5870ea7def8e..4f7936006a94 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -28,7 +28,6 @@ * under the License. */ -import React from 'react'; import { CoreStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/opensearch_dashboards_utils/public'; import { ExpressionsSetup } from 'src/plugins/expressions/public'; @@ -39,12 +38,13 @@ import { createFiltersFromRangeSelectAction, createFiltersFromValueClickAction } import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; import { QuerySetup, QueryStart } from './query'; import { IndexPatternsContract } from './index_patterns'; -import { IndexPatternSelectProps, StatefulSearchBarProps } from './ui'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { DataSourceStart } from './data_sources/datasource_services/types'; +import { IUiStart, UiEnhancements } from './ui'; export interface DataPublicPluginEnhancements { - search: SearchEnhancements; + search?: SearchEnhancements; + ui?: UiEnhancements; } export interface DataSetupDependencies { @@ -71,14 +71,6 @@ export interface DataPublicPluginSetup { __enhance: (enhancements: DataPublicPluginEnhancements) => void; } -/** - * Data plugin prewired UI components - */ -export interface DataPublicPluginStartUi { - IndexPatternSelect: React.ComponentType; - SearchBar: React.ComponentType; -} - /** * utilities to generate filters from action context */ @@ -122,10 +114,10 @@ export interface DataPublicPluginStart { */ query: QueryStart; /** - * prewired UI components - * {@link DataPublicPluginStartUi} + * UI components service + * {@link IUiStart} */ - ui: DataPublicPluginStartUi; + ui: IUiStart; /** * multiple datasources * {@link DataSourceStart} diff --git a/src/plugins/data/public/ui/index.ts b/src/plugins/data/public/ui/index.ts index c618df1783b5..98e6d393ce63 100644 --- a/src/plugins/data/public/ui/index.ts +++ b/src/plugins/data/public/ui/index.ts @@ -28,6 +28,7 @@ * under the License. */ +export { UiEnhancements, IUiStart, createSettings, Settings, DataSettings } from './types'; export { IndexPatternSelectProps } from './index_pattern_select'; export { FilterLabel } from './filter_bar'; export { QueryStringInput, QueryStringInputProps } from './query_string_input'; diff --git a/src/plugins/data/public/ui/mocks.ts b/src/plugins/data/public/ui/mocks.ts new file mode 100644 index 000000000000..47d3f059f504 --- /dev/null +++ b/src/plugins/data/public/ui/mocks.ts @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SettingsMock } from './settings/mocks'; +import { IUiSetup, IUiStart } from './types'; + +const createMockWebStorage = () => ({ + clear: jest.fn(), + getItem: jest.fn(), + key: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + length: 0, +}); + +const createMockStorage = () => ({ + storage: createMockWebStorage(), + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), +}); + +function createSetupContract(): jest.Mocked { + return { + __enhance: jest.fn(), + }; +} + +function createStartContract(isEnhancementsEnabled: boolean = false): jest.Mocked { + const queryEnhancements = new Map(); + return { + isEnhancementsEnabled, + queryEnhancements, + IndexPatternSelect: jest.fn(), + SearchBar: jest.fn(), + Settings: new SettingsMock(createMockStorage(), queryEnhancements), + }; +} + +export const uiServiceMock = { + createSetupContract, + createStartContract, +}; diff --git a/src/plugins/data/public/ui/query_string_input/_index.scss b/src/plugins/data/public/ui/query_string_input/_index.scss index 8686490016c5..f21b9cbb4327 100644 --- a/src/plugins/data/public/ui/query_string_input/_index.scss +++ b/src/plugins/data/public/ui/query_string_input/_index.scss @@ -1 +1,2 @@ @import "./query_bar"; +@import "./language_switcher" diff --git a/src/plugins/data/public/ui/query_string_input/_language_switcher.scss b/src/plugins/data/public/ui/query_string_input/_language_switcher.scss new file mode 100644 index 000000000000..176d072c102b --- /dev/null +++ b/src/plugins/data/public/ui/query_string_input/_language_switcher.scss @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.languageSelect { + max-width: 150px; + transform: translateY(-1px) translateX(-0.5px); +} diff --git a/src/plugins/data/public/ui/query_string_input/language_switcher.test.tsx b/src/plugins/data/public/ui/query_string_input/language_switcher.test.tsx index 22ec4e9edd96..dd1a3b4674cd 100644 --- a/src/plugins/data/public/ui/query_string_input/language_switcher.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/language_switcher.test.tsx @@ -33,9 +33,28 @@ import { QueryLanguageSwitcher } from './language_switcher'; import { OpenSearchDashboardsContextProvider } from 'src/plugins/opensearch_dashboards_react/public'; import { coreMock } from '../../../../../core/public/mocks'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { EuiButtonEmpty, EuiPopover } from '@elastic/eui'; +import { EuiComboBox } from '@elastic/eui'; +import { QueryEnhancement } from '../types'; + const startMock = coreMock.createStart(); +jest.mock('../../services', () => ({ + getUiService: () => ({ + isEnhancementsEnabled: true, + queryEnhancements: new Map(), + Settings: { + setUiOverridesByUserQueryLanguage: jest.fn(), + }, + }), + getSearchService: () => ({ + __enhance: jest.fn(), + df: { + clear: jest.fn(), + }, + getDefaultSearchInterceptor: jest.fn(), + }), +})); + describe('LanguageSwitcher', () => { function wrapInContext(testProps: any) { const services = { @@ -50,7 +69,7 @@ describe('LanguageSwitcher', () => { ); } - it('should toggle off if language is lucene', () => { + it('should select lucene if language is lucene', () => { const component = mountWithIntl( wrapInContext({ language: 'lucene', @@ -59,12 +78,17 @@ describe('LanguageSwitcher', () => { }, }) ); - component.find(EuiButtonEmpty).simulate('click'); - expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeFalsy(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual( + expect.arrayContaining([ + { + label: 'Lucene', + }, + ]) + ); }); - it('should toggle on if language is kuery', () => { + it('should select DQL if language is kuery', () => { const component = mountWithIntl( wrapInContext({ language: 'kuery', @@ -73,8 +97,13 @@ describe('LanguageSwitcher', () => { }, }) ); - component.find(EuiButtonEmpty).simulate('click'); - expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeTruthy(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual( + expect.arrayContaining([ + { + label: 'DQL', + }, + ]) + ); }); }); diff --git a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx index 816c21bc0848..a2d1f9ca41ce 100644 --- a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx +++ b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx @@ -28,116 +28,100 @@ * under the License. */ -import { - EuiButtonEmpty, - EuiForm, - EuiFormRow, - EuiLink, - EuiPopover, - EuiPopoverTitle, - EuiSpacer, - EuiSwitch, - EuiText, - PopoverAnchorPosition, -} from '@elastic/eui'; -import { FormattedMessage } from '@osd/i18n/react'; -import React, { useState } from 'react'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { EuiComboBox, EuiComboBoxOptionOption, PopoverAnchorPosition } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React from 'react'; +import { getSearchService, getUiService } from '../../services'; interface Props { language: string; onSelectLanguage: (newLanguage: string) => void; anchorPosition?: PopoverAnchorPosition; + appName?: string; +} + +function mapExternalLanguageToOptions(language: string) { + return { + label: language, + value: language, + }; } export function QueryLanguageSwitcher(props: Props) { - const osdDQLDocs = useOpenSearchDashboards().services.docLinks?.links.opensearchDashboards.dql - .base; - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const luceneLabel = ( - - ); - const dqlLabel = ( - - ); - const dqlFullName = ( - - ); + const dqlLabel = i18n.translate('data.query.queryBar.dqlLanguageName', { + defaultMessage: 'DQL', + }); + const luceneLabel = i18n.translate('data.query.queryBar.luceneLanguageName', { + defaultMessage: 'Lucene', + }); - const button = ( - setIsPopoverOpen(!isPopoverOpen)} - className="euiFormControlLayout__append dqlQueryBar__languageSwitcherButton" - data-test-subj={'switchQueryLanguageButton'} - > - {props.language === 'lucene' ? luceneLabel : dqlLabel} - - ); + const languageOptions: EuiComboBoxOptionOption[] = [ + { + label: dqlLabel, + value: 'kuery', + }, + { + label: luceneLabel, + value: 'lucene', + }, + ]; - return ( - setIsPopoverOpen(false)} - repositionOnScroll - > - - - -
- -

- - {dqlFullName} - - ), - }} - /> -

-
+ const uiService = getUiService(); + const searchService = getSearchService(); - + const queryEnhancements = uiService.queryEnhancements; + if (uiService.isEnhancementsEnabled) { + queryEnhancements.forEach((enhancement) => { + if ( + enhancement.supportedAppNames && + props.appName && + !enhancement.supportedAppNames.includes(props.appName) + ) + return; + languageOptions.push(mapExternalLanguageToOptions(enhancement.language)); + }); + } - - - - ) : ( - - ) - } - checked={props.language === 'kuery'} - onChange={() => { - const newLanguage = props.language === 'lucene' ? 'kuery' : 'lucene'; - props.onSelectLanguage(newLanguage); - }} - data-test-subj="languageToggle" - /> - - -
-
+ const selectedLanguage = { + label: + (languageOptions.find( + (option) => (option.value as string).toLowerCase() === props.language.toLowerCase() + )?.label as string) ?? languageOptions[0].label, + }; + + const setSearchEnhance = (queryLanguage: string) => { + if (!uiService.isEnhancementsEnabled) return; + const queryEnhancement = queryEnhancements.get(queryLanguage); + searchService.__enhance({ + searchInterceptor: queryEnhancement + ? queryEnhancement.search + : searchService.getDefaultSearchInterceptor(), + }); + + if (!queryEnhancement) { + searchService.df.clear(); + } + uiService.Settings.setUiOverridesByUserQueryLanguage(queryLanguage); + }; + + const handleLanguageChange = (newLanguage: EuiComboBoxOptionOption[]) => { + const queryLanguage = newLanguage[0].value as string; + props.onSelectLanguage(queryLanguage); + setSearchEnhance(queryLanguage); + }; + + setSearchEnhance(props.language); + + return ( + ); } diff --git a/src/plugins/data/public/ui/query_string_input/legacy_language_switcher.test.tsx b/src/plugins/data/public/ui/query_string_input/legacy_language_switcher.test.tsx new file mode 100644 index 000000000000..7e7e43190bb4 --- /dev/null +++ b/src/plugins/data/public/ui/query_string_input/legacy_language_switcher.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { LegacyQueryLanguageSwitcher } from './legacy_language_switcher'; +import { OpenSearchDashboardsContextProvider } from 'src/plugins/opensearch_dashboards_react/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { EuiButtonEmpty, EuiPopover } from '@elastic/eui'; +const startMock = coreMock.createStart(); + +describe('LegacyLanguageSwitcher', () => { + function wrapInContext(testProps: any) { + const services = { + uiSettings: startMock.uiSettings, + docLinks: startMock.docLinks, + }; + + return ( + + + + ); + } + + it('should toggle off if language is lucene', () => { + const component = mountWithIntl( + wrapInContext({ + language: 'lucene', + onSelectLanguage: () => { + return; + }, + }) + ); + component.find(EuiButtonEmpty).simulate('click'); + expect(component.find(EuiPopover).prop('isOpen')).toBe(true); + expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeFalsy(); + }); + + it('should toggle on if language is kuery', () => { + const component = mountWithIntl( + wrapInContext({ + language: 'kuery', + onSelectLanguage: () => { + return; + }, + }) + ); + component.find(EuiButtonEmpty).simulate('click'); + expect(component.find(EuiPopover).prop('isOpen')).toBe(true); + expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeTruthy(); + }); +}); diff --git a/src/plugins/data/public/ui/query_string_input/legacy_language_switcher.tsx b/src/plugins/data/public/ui/query_string_input/legacy_language_switcher.tsx new file mode 100644 index 000000000000..b8128a950d33 --- /dev/null +++ b/src/plugins/data/public/ui/query_string_input/legacy_language_switcher.tsx @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiLink, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiSwitch, + EuiText, + PopoverAnchorPosition, +} from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { useState } from 'react'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; + +interface Props { + language: string; + onSelectLanguage: (newLanguage: string) => void; + anchorPosition?: PopoverAnchorPosition; +} + +export function LegacyQueryLanguageSwitcher(props: Props) { + const osdDQLDocs = useOpenSearchDashboards().services.docLinks?.links.opensearchDashboards.dql + .base; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const luceneLabel = ( + + ); + const dqlLabel = ( + + ); + const dqlFullName = ( + + ); + + const button = ( + setIsPopoverOpen(!isPopoverOpen)} + className="euiFormControlLayout__append dqlQueryBar__languageSwitcherButton" + data-test-subj={'switchQueryLanguageButton'} + > + {props.language === 'lucene' ? luceneLabel : dqlLabel} + + ); + + return ( + setIsPopoverOpen(false)} + repositionOnScroll + > + + + +
+ +

+ + {dqlFullName} + + ), + }} + /> +

+
+ + + + + + + ) : ( + + ) + } + checked={props.language === 'kuery'} + onChange={() => { + const newLanguage = props.language === 'lucene' ? 'kuery' : 'lucene'; + props.onSelectLanguage(newLanguage); + }} + data-test-subj="languageToggle" + /> + + +
+
+ ); +} diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx index 3b8c41eb1a39..fa194054930a 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx @@ -70,6 +70,8 @@ startMock.uiSettings.get.mockImplementation((key: string) => { from: 'now-15m', to: 'now', }; + case UI_SETTINGS.QUERY_DATA_SOURCE_READONLY: + return false; default: throw new Error(`Unexpected config key: ${key}`); } diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index 8c509d573e1b..027c90a6c798 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -46,6 +46,7 @@ import { import { EuiSuperUpdateButton, OnRefreshProps } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; import { Toast } from 'src/core/public'; +import { isEqual, compact } from 'lodash'; import { IDataPluginServices, IIndexPattern, TimeRange, TimeHistoryContract, Query } from '../..'; import { useOpenSearchDashboards, @@ -54,14 +55,18 @@ import { } from '../../../../opensearch_dashboards_react/public'; import QueryStringInputUI from './query_string_input'; import { doesKueryExpressionHaveLuceneSyntaxError, UI_SETTINGS } from '../../../common'; -import { PersistedLog, getQueryLog } from '../../query'; +import { PersistedLog, fromUser, getQueryLog } from '../../query'; import { NoDataPopover } from './no_data_popover'; +import { QueryEnhancement, Settings } from '../types'; const QueryStringInput = withOpenSearchDashboards(QueryStringInputUI); // @internal export interface QueryBarTopRowProps { query?: Query; + isEnhancementsEnabled?: boolean; + queryEnhancements?: Map; + settings?: Settings; onSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void; onChange: (payload: { dateRange: TimeRange; query?: Query }) => void; onRefresh?: (payload: { dateRange: TimeRange }) => void; @@ -95,8 +100,22 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) { const { uiSettings, notifications, storage, appName, docLinks } = opensearchDashboards.services; const osdDQLDocs: string = docLinks!.links.opensearchDashboards.dql.base; + const isDataSourceReadOnly = uiSettings.get('query:dataSourceReadOnly'); const queryLanguage = props.query && props.query.language; + const queryUiEnhancement = + (queryLanguage && + props.queryEnhancements && + props.queryEnhancements.get(queryLanguage)?.searchBar) || + null; + const parsedQuery = + !queryUiEnhancement || isValidQuery(props.query) + ? props.query! + : { query: getQueryStringInitialValue(queryLanguage!), language: queryLanguage! }; + if (!isEqual(parsedQuery?.query, props.query?.query)) { + onQueryChange(parsedQuery); + onSubmit({ query: parsedQuery, dateRange: getDateRange() }); + } const persistedLog: PersistedLog | undefined = React.useMemo( () => queryLanguage && uiSettings && storage && appName @@ -116,15 +135,19 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) { function getDateRange() { const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); return { - from: props.dateRangeFrom || defaultTimeSetting.from, - to: props.dateRangeTo || defaultTimeSetting.to, + from: + props.dateRangeFrom || + queryUiEnhancement?.dateRange?.initialFrom || + defaultTimeSetting.from, + to: props.dateRangeTo || queryUiEnhancement?.dateRange?.initialTo || defaultTimeSetting.to, }; } - function onQueryChange(query: Query) { + function onQueryChange(query: Query, dateRange?: TimeRange) { + if (queryUiEnhancement && !isValidQuery(query)) return; props.onChange({ query, - dateRange: getDateRange(), + dateRange: dateRange ?? getDateRange(), }); } @@ -181,10 +204,10 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) { props.onSubmit({ query, dateRange }); } - function onInputSubmit(query: Query) { + function onInputSubmit(query: Query, dateRange?: TimeRange) { onSubmit({ query, - dateRange: getDateRange(), + dateRange: dateRange ?? getDateRange(), }); } @@ -196,6 +219,38 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) { return valueAsMoment.toISOString(); } + function isValidQuery(query: Query | undefined) { + if (!query || !query.query) return false; + return ( + !Array.isArray(props.indexPatterns!) || + compact(props.indexPatterns!).length === 0 || + !isDataSourceReadOnly || + fromUser(query!.query).includes( + typeof props.indexPatterns[0] === 'string' + ? props.indexPatterns[0] + : props.indexPatterns[0].title + ) + ); + } + + function getQueryStringInitialValue(language: string) { + const { indexPatterns, queryEnhancements } = props; + const input = queryEnhancements?.get(language)?.searchBar?.queryStringInput?.initialValue; + + if ( + !indexPatterns || + (!Array.isArray(indexPatterns) && compact(indexPatterns).length > 0) || + !input + ) + return ''; + + const defaultDataSource = indexPatterns[0]; + const dataSource = + typeof defaultDataSource === 'string' ? defaultDataSource : defaultDataSource.title; + + return input.replace('', dataSource); + } + function renderQueryInput() { if (!shouldRenderQueryInput()) return; return ( @@ -204,11 +259,15 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) { disableAutoFocus={props.disableAutoFocus} indexPatterns={props.indexPatterns!} prepend={props.prepend} - query={props.query!} + query={parsedQuery} + isEnhancementsEnabled={props.isEnhancementsEnabled} + queryEnhancements={props.queryEnhancements} + settings={props.settings} screenTitle={props.screenTitle} onChange={onQueryChange} onChangeQueryInputFocus={onChangeQueryInputFocus} onSubmit={onInputSubmit} + getQueryStringInitialValue={getQueryStringInitialValue} persistedLog={persistedLog} dataTestSubj={props.dataTestSubj} /> @@ -233,10 +292,15 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) { } function shouldRenderDatePicker(): boolean { - return Boolean(props.showDatePicker || props.showAutoRefreshOnly); + return Boolean( + (props.showDatePicker && (queryUiEnhancement?.showDatePicker ?? true)) ?? + (props.showAutoRefreshOnly && (queryUiEnhancement?.showAutoRefreshOnly ?? true)) + ); } function shouldRenderQueryInput(): boolean { + // TODO: MQL probably can modify to not care about index patterns + // TODO: call queryUiEnhancement?.showQueryInput return Boolean(props.showQueryInput && props.indexPatterns && props.query && storage); } diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx index dfa5d57411d0..da5cc0e017b2 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx @@ -42,7 +42,7 @@ import { render } from '@testing-library/react'; import { EuiTextArea } from '@elastic/eui'; -import { QueryLanguageSwitcher } from './language_switcher'; +import { LegacyQueryLanguageSwitcher } from './legacy_language_switcher'; import { QueryStringInput } from './'; import type QueryStringInputUI from './query_string_input'; @@ -50,6 +50,7 @@ import { coreMock } from '../../../../../core/public/mocks'; import { dataPluginMock } from '../../mocks'; import { stubIndexPatternWithFields } from '../../stubs'; import { OpenSearchDashboardsContextProvider } from 'src/plugins/opensearch_dashboards_react/public'; +import { SettingsMock } from '../settings/mocks'; const startMock = coreMock.createStart(); @@ -84,6 +85,8 @@ const createMockStorage = () => ({ clear: jest.fn(), }); +const settingsMock = new SettingsMock(createMockStorage(), new Map()); + function wrapQueryStringInputInContext(testProps: any, storage?: any) { const defaultOptions = { screenTitle: 'Another Screen', @@ -132,7 +135,7 @@ describe('QueryStringInput', () => { indexPatterns: [stubIndexPatternWithFields], }) ); - expect(component.find(QueryLanguageSwitcher).prop('language')).toBe(luceneQuery.language); + expect(component.find(LegacyQueryLanguageSwitcher).prop('language')).toBe(luceneQuery.language); }); it('Should disable autoFocus on EuiTextArea when disableAutoFocus prop is true', () => { @@ -173,16 +176,17 @@ describe('QueryStringInput', () => { indexPatterns: [stubIndexPatternWithFields], disableAutoFocus: true, appName: 'discover', + settings: settingsMock, }, mockStorage ) ); - component.find(QueryLanguageSwitcher).props().onSelectLanguage('lucene'); - expect(mockStorage.set).toHaveBeenCalledWith( - 'opensearchDashboards.userQueryLanguage', - 'lucene' - ); + component.find(LegacyQueryLanguageSwitcher).props().onSelectLanguage('lucene'); + expect(settingsMock.updateSettings).toHaveBeenCalledWith({ + userQueryLanguage: 'lucene', + userQueryString: '', + }); expect(mockCallback).toHaveBeenCalledWith({ query: '', language: 'lucene' }); }); diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 5d071748700c..db0d732d1db6 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -47,7 +47,7 @@ import { import { FormattedMessage } from '@osd/i18n/react'; import { debounce, compact, isEqual, isFunction } from 'lodash'; import { Toast } from 'src/core/public'; -import { IDataPluginServices, IIndexPattern, Query } from '../..'; +import { IDataPluginServices, IIndexPattern, Query, TimeRange } from '../..'; import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; import { @@ -56,13 +56,18 @@ import { } from '../../../../opensearch_dashboards_react/public'; import { fetchIndexPatterns } from './fetch_index_patterns'; import { QueryLanguageSwitcher } from './language_switcher'; +import { LegacyQueryLanguageSwitcher } from './legacy_language_switcher'; import { PersistedLog, getQueryLog, matchPairs, toUser, fromUser } from '../../query'; import { SuggestionsListSize } from '../typeahead/suggestions_component'; -import { SuggestionsComponent } from '..'; +import { Settings, SuggestionsComponent } from '..'; +import { DataSettings, QueryEnhancement } from '../types'; export interface QueryStringInputProps { indexPatterns: Array; query: Query; + isEnhancementsEnabled?: boolean; + queryEnhancements?: Map; + settings?: Settings; disableAutoFocus?: boolean; screenTitle?: string; prepend?: any; @@ -71,9 +76,10 @@ export interface QueryStringInputProps { placeholder?: string; languageSwitcherPopoverAnchorPosition?: PopoverAnchorPosition; onBlur?: () => void; - onChange?: (query: Query) => void; + onChange?: (query: Query, dateRange?: TimeRange) => void; onChangeQueryInputFocus?: (isFocused: boolean) => void; - onSubmit?: (query: Query) => void; + onSubmit?: (query: Query, dateRange?: TimeRange) => void; + getQueryStringInitialValue?: (language: string) => string; dataTestSubj?: string; size?: SuggestionsListSize; className?: string; @@ -108,6 +114,7 @@ const KEY_CODES = { }; // Needed for React.lazy +// TODO: MQL export this and let people extended this // eslint-disable-next-line import/no-default-export export default class QueryStringInputUI extends Component { public state: State = { @@ -130,9 +137,13 @@ export default class QueryStringInputUI extends Component { private queryBarInputDivRefInstance: RefObject = createRef(); private getQueryString = () => { + if (!this.props.query.query) { + return this.props.getQueryStringInitialValue?.(this.props.query.language) ?? ''; + } return toUser(this.props.query.query); }; + // TODO: MQL don't do this here? || Fetch data sources private fetchIndexPatterns = async () => { const stringPatterns = this.props.indexPatterns.filter( (indexPattern) => typeof indexPattern === 'string' @@ -224,7 +235,7 @@ export default class QueryStringInputUI extends Component { } }, 100); - private onSubmit = (query: Query) => { + private onSubmit = (query: Query, dateRange?: TimeRange) => { if (this.props.onSubmit) { if (this.persistedLog) { this.persistedLog.add(query.query); @@ -234,11 +245,11 @@ export default class QueryStringInputUI extends Component { } }; - private onChange = (query: Query) => { + private onChange = (query: Query, dateRange?: TimeRange) => { this.updateSuggestions(); if (this.props.onChange) { - this.props.onChange({ query: fromUser(query.query), language: query.language }); + this.props.onChange({ query: fromUser(query.query), language: query.language }, dateRange); } }; @@ -457,6 +468,7 @@ export default class QueryStringInputUI extends Component { } }; + // TODO: MQL consider moving language select language of setting search source here private onSelectLanguage = (language: string) => { // Send telemetry info every time the user opts in or out of kuery // As a result it is important this function only ever gets called in the @@ -465,11 +477,28 @@ export default class QueryStringInputUI extends Component { body: JSON.stringify({ opt_in: language === 'kuery' }), }); - this.services.storage.set('opensearchDashboards.userQueryLanguage', language); + const newQuery = { + query: this.props.getQueryStringInitialValue?.(language) ?? '', + language, + }; - const newQuery = { query: '', language }; - this.onChange(newQuery); - this.onSubmit(newQuery); + const fields = this.props.queryEnhancements?.get(newQuery.language)?.fields; + const newSettings: DataSettings = { + userQueryLanguage: newQuery.language, + userQueryString: newQuery.query, + ...(fields && { uiOverrides: { fields } }), + }; + this.props.settings?.updateSettings(newSettings); + + const dateRangeEnhancement = this.props.queryEnhancements?.get(language)?.searchBar?.dateRange; + const dateRange = dateRangeEnhancement + ? { + from: dateRangeEnhancement.initialFrom!, + to: dateRangeEnhancement.initialTo!, + } + : undefined; + this.onChange(newQuery, dateRange); + this.onSubmit(newQuery, dateRange); }; private onOutsideClick = () => { @@ -619,6 +648,14 @@ export default class QueryStringInputUI extends Component { return (
{this.props.prepend} + {!!this.props.isEnhancementsEnabled && ( + + )}
{
- - + {!!!this.props.isEnhancementsEnabled && ( + + )}
); } diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index c739b955ff19..b2fdea2b49c9 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -41,11 +41,15 @@ import { useSavedQuery } from './lib/use_saved_query'; import { DataPublicPluginStart } from '../../types'; import { Filter, Query, TimeRange } from '../../../common'; import { useQueryStringManager } from './lib/use_query_string_manager'; +import { QueryEnhancement, Settings } from '../types'; interface StatefulSearchBarDeps { core: CoreStart; data: Omit; storage: IStorageWrapper; + isEnhancementsEnabled: boolean; + queryEnhancements: Map; + settings: Settings; } export type StatefulSearchBarProps = SearchBarOwnProps & { @@ -130,11 +134,19 @@ const overrideDefaultBehaviors = (props: StatefulSearchBarProps) => { return props.useDefaultBehaviors ? {} : props; }; -export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) { +export function createSearchBar({ + core, + storage, + data, + isEnhancementsEnabled, + queryEnhancements, + settings, +}: StatefulSearchBarDeps) { // App name should come from the core application service. // Until it's available, we'll ask the user to provide it for the pre-wired component. return (props: StatefulSearchBarProps) => { const { useDefaultBehaviors } = props; + // Handle queries const onQuerySubmitRef = useRef(props.onQuerySubmit); @@ -148,6 +160,7 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) query: props.query, queryStringManager: data.query.queryString, }); + const { timeRange, refreshInterval } = useTimefilter({ dateRangeFrom: props.dateRangeFrom, dateRangeTo: props.dateRangeTo, @@ -201,6 +214,9 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) isRefreshPaused={refreshInterval.pause} filters={filters} query={query} + isEnhancementsEnabled={isEnhancementsEnabled} + queryEnhancements={queryEnhancements} + settings={settings} onFiltersUpdated={defaultFiltersUpdated(data.query)} onRefreshChange={defaultOnRefreshChange(data.query)} savedQuery={savedQuery} diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index b05b18b6d64e..937da74914a0 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -47,6 +47,7 @@ import { TimeRange, Query, Filter, IIndexPattern } from '../../../common'; import { FilterBar } from '../filter_bar/filter_bar'; import { SavedQueryMeta, SaveQueryForm } from '../saved_query_form'; import { SavedQueryManagementComponent } from '../saved_query_management'; +import { QueryEnhancement, Settings } from '../types'; interface SearchBarInjectedDeps { opensearchDashboards: OpenSearchDashboardsReactContextValue; @@ -78,6 +79,9 @@ export interface SearchBarOwnProps { dateRangeTo?: string; // Query bar - should be in SearchBarInjectedDeps query?: Query; + isEnhancementsEnabled?: boolean; + queryEnhancements?: Map; + settings?: Settings; // Show when user has privileges to save showSaveQuery?: boolean; savedQuery?: SavedQuery; @@ -96,6 +100,7 @@ export interface SearchBarOwnProps { export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; +// TODO: MQL: include query enhancement in state in case make adding data sources at runtime? interface State { isFiltersVisible: boolean; showSaveQueryModal: boolean; @@ -202,6 +207,7 @@ class SearchBarUI extends Component { }; private shouldRenderQueryBar() { + // TODO: MQL handle no index patterns? const showDatePicker = this.props.showDatePicker || this.props.showAutoRefreshOnly; const showQueryInput = this.props.showQueryInput && this.props.indexPatterns && this.state.query; @@ -209,11 +215,14 @@ class SearchBarUI extends Component { } private shouldRenderFilterBar() { + // TODO: MQL handle no index patterns? return ( this.props.showFilterBar && this.props.filters && this.props.indexPatterns && - compact(this.props.indexPatterns).length > 0 + compact(this.props.indexPatterns).length > 0 && + (this.props.queryEnhancements?.get(this.state.query?.language!)?.searchBar?.showFilterBar ?? + true) ); } @@ -393,9 +402,13 @@ class SearchBarUI extends Component { let queryBar; if (this.shouldRenderQueryBar()) { + // TODO: MQL make this default query bar top row but this.props.queryEnhancements.get(language) can pass a component queryBar = ( + ) {} + + getUserQueryLanguage() { + return this.storage.get('opensearchDashboards.userQueryLanguage') || 'kuery'; + } + + setUserQueryLanguage(language: string) { + this.storage.set('opensearchDashboards.userQueryLanguage', language); + return true; + } + + getUserQueryString() { + return this.storage.get('opensearchDashboards.userQueryString') || ''; + } + + setUserQueryString(query: string) { + this.storage.set('opensearchDashboards.userQueryString', query); + return true; + } + + getUiOverrides() { + return this.storage.get('opensearchDashboards.uiOverrides') || {}; + } + + setUiOverrides(overrides?: { [key: string]: any }) { + if (!overrides) { + this.storage.remove('opensearchDashboards.uiOverrides'); + setFieldOverrides(undefined); + return true; + } + this.storage.set('opensearchDashboards.uiOverrides', overrides); + setFieldOverrides(overrides.fields); + return true; + } + + setUiOverridesByUserQueryLanguage(language: string) { + const queryEnhancement = this.queryEnhancements.get(language); + if (queryEnhancement) { + const { fields = {}, showDocLinks } = queryEnhancement; + this.setUiOverrides({ fields, showDocLinks }); + } else { + this.setUiOverrides({ fields: undefined, showDocLinks: undefined }); + } + } + + toJSON(): DataSettings { + return { + userQueryLanguage: this.getUserQueryLanguage(), + userQueryString: this.getUserQueryString(), + uiOverrides: this.getUiOverrides(), + }; + } + + updateSettings({ userQueryLanguage, userQueryString, uiOverrides }: DataSettings) { + this.setUserQueryLanguage(userQueryLanguage); + this.setUserQueryString(userQueryString); + this.setUiOverrides(uiOverrides); + } +} + +interface Deps { + storage: IStorageWrapper; + queryEnhancements: Map; +} + +export function createSettings({ storage, queryEnhancements }: Deps) { + return new Settings(storage, queryEnhancements); +} diff --git a/src/plugins/data/public/ui/types.ts b/src/plugins/data/public/ui/types.ts new file mode 100644 index 000000000000..464e6a97afde --- /dev/null +++ b/src/plugins/data/public/ui/types.ts @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SearchInterceptor } from '../search'; +import { IndexPatternSelectProps } from './index_pattern_select'; +import { StatefulSearchBarProps } from './search_bar'; +import { Settings } from './settings'; + +export * from './settings'; + +export interface QueryEnhancement { + // TODO: MQL do want to default have supported all data_sources? + // or should data connect have a record of query enhancements that are supported + language: string; + search: SearchInterceptor; + // Leave blank to support all data sources + // supportedDataSourceTypes?: Record; + searchBar?: { + showQueryInput?: boolean; + showFilterBar?: boolean; + showDatePicker?: boolean; + showAutoRefreshOnly?: boolean; + queryStringInput?: { + // will replace '' with the data source name + initialValue?: string; + }; + dateRange?: { + initialFrom?: string; + initialTo?: string; + }; + }; + fields?: { + filterable?: boolean; + visualizable?: boolean; + }; + showDocLinks?: boolean; + // List of supported app names that this enhancement should be enabled for, + // if not provided it will be enabled for all apps + supportedAppNames?: string[]; +} + +export interface UiEnhancements { + query?: QueryEnhancement; +} + +/** + * The setup contract exposed by the Search plugin exposes the search strategy extension + * point. + */ +export interface IUiSetup { + __enhance: (enhancements: UiEnhancements) => void; +} + +/** + * Data plugin prewired UI components + */ +export interface IUiStart { + isEnhancementsEnabled: boolean; + queryEnhancements: Map; + IndexPatternSelect: React.ComponentType; + SearchBar: React.ComponentType; + Settings: Settings; +} diff --git a/src/plugins/data/public/ui/ui_service.ts b/src/plugins/data/public/ui/ui_service.ts new file mode 100644 index 000000000000..1ef834b54564 --- /dev/null +++ b/src/plugins/data/public/ui/ui_service.ts @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Plugin, CoreSetup, CoreStart, PluginInitializerContext } from 'src/core/public'; +import { IUiStart, IUiSetup, QueryEnhancement, UiEnhancements } from './types'; + +import { ConfigSchema } from '../../config'; +import { createIndexPatternSelect } from './index_pattern_select'; +import { createSearchBar } from './search_bar/create_search_bar'; +import { createSettings } from './settings'; +import { DataPublicPluginStart } from '../types'; +import { IStorageWrapper } from '../../../opensearch_dashboards_utils/public'; + +/** @internal */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface UiServiceSetupDependencies {} + +/** @internal */ +export interface UiServiceStartDependencies { + dataServices: Omit; + storage: IStorageWrapper; +} + +export class UiService implements Plugin { + enhancementsConfig: ConfigSchema['enhancements']; + private queryEnhancements: Map = new Map(); + + constructor(initializerContext: PluginInitializerContext) { + const { enhancements } = initializerContext.config.get(); + + this.enhancementsConfig = enhancements; + } + + public setup(core: CoreSetup, {}: UiServiceSetupDependencies): IUiSetup { + return { + __enhance: (enhancements?: UiEnhancements) => { + if (!enhancements) return; + if (!this.enhancementsConfig.enabled) return; + if (enhancements.query && enhancements.query.language) { + this.queryEnhancements.set(enhancements.query.language, enhancements.query); + } + }, + }; + } + + public start(core: CoreStart, { dataServices, storage }: UiServiceStartDependencies): IUiStart { + const Settings = createSettings({ storage, queryEnhancements: this.queryEnhancements }); + + const SearchBar = createSearchBar({ + core, + data: dataServices, + storage, + isEnhancementsEnabled: this.enhancementsConfig?.enabled, + queryEnhancements: this.queryEnhancements, + settings: Settings, + }); + + return { + isEnhancementsEnabled: this.enhancementsConfig?.enabled, + queryEnhancements: this.queryEnhancements, + IndexPatternSelect: createIndexPatternSelect(core.savedObjects.client), + SearchBar, + Settings, + }; + } + + public stop() {} +} diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 4bc3ad62a4ae..5fe531729283 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -133,11 +133,13 @@ export { IFieldFormatsRegistry, FieldFormatsGetConfigFn, FieldFormatConfig } fro * Index patterns: */ -import { isNestedField, isFilterable } from '../common'; +import { isNestedField, isFilterable, setOverrides, getOverrides } from '../common'; export const indexPatterns = { isFilterable, isNestedField, + setOverrides, + getOverrides, }; export { @@ -272,6 +274,8 @@ export const search = { export { // osd field types castOpenSearchToOsdFieldTypeName, + getOsdFieldOverrides, + setOsdFieldOverrides, // query Filter, getTime, @@ -300,6 +304,7 @@ export { export const config: PluginConfigDescriptor = { exposeToBrowser: { + enhancements: true, autocomplete: true, search: true, }, diff --git a/src/plugins/data/server/search/opensearch_search/get_default_search_params.ts b/src/plugins/data/server/search/opensearch_search/get_default_search_params.ts index d7cbd48a6507..172acf9f0e2e 100644 --- a/src/plugins/data/server/search/opensearch_search/get_default_search_params.ts +++ b/src/plugins/data/server/search/opensearch_search/get_default_search_params.ts @@ -45,10 +45,14 @@ export async function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient const maxConcurrentShardRequests = await uiSettingsClient.get( UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS ); + const dataFrameHydrationStrategy = await uiSettingsClient.get( + UI_SETTINGS.DATAFRAME_HYDRATION_STRATEGY + ); return { maxConcurrentShardRequests: maxConcurrentShardRequests > 0 ? maxConcurrentShardRequests : undefined, ignoreThrottled, + dataFrameHydrationStrategy, ignoreUnavailable: true, // Don't fail if the index/indices don't exist trackTotalHits: true, }; diff --git a/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.ts b/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.ts index fa1b3e4da94c..c5c7602bc4f9 100644 --- a/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.ts +++ b/src/plugins/data/server/search/opensearch_search/opensearch_search_strategy.ts @@ -64,8 +64,12 @@ export const opensearchSearchStrategyProvider = ( throw new Error(`Unsupported index pattern type ${request.indexType}`); } - // ignoreThrottled is not supported in OSS - const { ignoreThrottled, ...defaultParams } = await getDefaultSearchParams(uiSettingsClient); + // ignoreThrottled & dataFrameHydrationStrategy is not supported by default + const { + ignoreThrottled, + dataFrameHydrationStrategy, + ...defaultParams + } = await getDefaultSearchParams(uiSettingsClient); const params = toSnakeCase({ ...defaultParams, diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index dc257620fcca..b6bca0875d40 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -80,8 +80,12 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { const config = await globalConfig$.pipe(first()).toPromise(); const timeout = getShardTimeout(config); - // trackTotalHits is not supported by msearch - const { trackTotalHits, ...defaultParams } = await getDefaultSearchParams(uiSettings); + // trackTotalHits and dataFrameHydrationStrategy is not supported by msearch + const { + trackTotalHits, + dataFrameHydrationStrategy, + ...defaultParams + } = await getDefaultSearchParams(uiSettings); const body = convertRequestBody(params.body, timeout); diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index b955596922a0..2eef461b94da 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -73,6 +73,13 @@ import { } from '../../common/search/aggs/buckets/shard_delay'; import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn'; import { ConfigSchema } from '../../config'; +import { + DataFrameService, + IDataFrame, + IDataFrameResponse, + createDataFrameCache, + dataFrameToSpec, +} from '../../common'; type StrategyMap = Record>; @@ -98,6 +105,7 @@ export interface SearchRouteDependencies { export class SearchService implements Plugin { private readonly aggsService = new AggsService(); private readonly searchSourceService = new SearchSourceService(); + private readonly dfCache = createDataFrameCache(); private defaultSearchStrategyName: string = OPENSEARCH_SEARCH_STRATEGY; private searchStrategies: StrategyMap = {}; @@ -166,7 +174,8 @@ export class SearchService implements Plugin { }); return { - __enhance: (enhancements: SearchEnhancements) => { + __enhance: (enhancements?: SearchEnhancements) => { + if (!enhancements) return; if (this.searchStrategies.hasOwnProperty(enhancements.defaultStrategy)) { this.defaultSearchStrategyName = enhancements.defaultStrategy; } @@ -203,6 +212,29 @@ export class SearchService implements Plugin { searchSourceRequiredUiSettings ); + const dfService: DataFrameService = { + get: () => this.dfCache.get(), + set: async (dataFrame: IDataFrame) => { + if (this.dfCache.get() && this.dfCache.get()?.name !== dataFrame.name) { + scopedIndexPatterns.clearCache(this.dfCache.get()!.name, false); + } + this.dfCache.set(dataFrame); + const existingIndexPattern = scopedIndexPatterns.getByTitle(dataFrame.name!, true); + const dataSet = await scopedIndexPatterns.create( + dataFrameToSpec(dataFrame, existingIndexPattern?.id), + !existingIndexPattern?.id + ); + // save to cache by title because the id is not unique for temporary index pattern created + scopedIndexPatterns.saveToCache(dataSet.title, dataSet); + }, + clear: () => { + if (this.dfCache.get() === undefined) return; + // name because the id is not unique for temporary index pattern created + scopedIndexPatterns.clearCache(this.dfCache.get()!.name, false); + this.dfCache.clear(); + }, + }; + const searchSourceDependencies: SearchSourceDependencies = { getConfig: (key: string): T => uiSettingsCache[key], search: (searchRequest, options) => { @@ -237,6 +269,7 @@ export class SearchService implements Plugin { }), loadingCount$: new BehaviorSubject(0), }, + df: dfService, }; return this.searchSourceService.start(scopedIndexPatterns, searchSourceDependencies); @@ -251,7 +284,9 @@ export class SearchService implements Plugin { private registerSearchStrategy = < SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest, - SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse + SearchStrategyResponse extends + | IOpenSearchDashboardsSearchResponse + | IDataFrameResponse = IOpenSearchSearchResponse >( name: string, strategy: ISearchStrategy @@ -261,7 +296,9 @@ export class SearchService implements Plugin { private search = < SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest, - SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse + SearchStrategyResponse extends + | IOpenSearchDashboardsSearchResponse + | IDataFrameResponse = IOpenSearchSearchResponse >( context: RequestHandlerContext, searchRequest: SearchStrategyRequest, @@ -274,7 +311,9 @@ export class SearchService implements Plugin { private getSearchStrategy = < SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest, - SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse + SearchStrategyResponse extends + | IOpenSearchDashboardsSearchResponse + | IDataFrameResponse = IOpenSearchSearchResponse >( name: string ): ISearchStrategy => { diff --git a/src/plugins/data/server/search/types.ts b/src/plugins/data/server/search/types.ts index 75f21d39c0bf..6927d1289673 100644 --- a/src/plugins/data/server/search/types.ts +++ b/src/plugins/data/server/search/types.ts @@ -38,6 +38,7 @@ import { import { AggsSetup, AggsStart } from './aggs'; import { SearchUsage } from './collectors'; import { IOpenSearchSearchRequest, IOpenSearchSearchResponse } from './opensearch_search'; +import { IDataFrameResponse } from '../../common'; export interface SearchEnhancements { defaultStrategy: string; @@ -51,7 +52,9 @@ export interface ISearchSetup { */ registerSearchStrategy: < SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest, - SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse + SearchStrategyResponse extends + | IOpenSearchDashboardsSearchResponse + | IDataFrameResponse = IOpenSearchSearchResponse >( name: string, strategy: ISearchStrategy @@ -96,7 +99,9 @@ export interface ISearchStart< */ export interface ISearchStrategy< SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest, - SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse + SearchStrategyResponse extends + | IOpenSearchDashboardsSearchResponse + | IDataFrameResponse = IOpenSearchSearchResponse > { search: ( context: RequestHandlerContext, diff --git a/src/plugins/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index 77f4afd11887..e12113b87b29 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -55,6 +55,21 @@ const requestPreferenceOptionLabels = { }), }; +const dataFrameHydrationStrategyOptionLabels = { + perSource: i18n.translate('data.advancedSettings.dataFrameHydrationStrategyPerSourceText', { + defaultMessage: 'On data source change', + }), + perQuery: i18n.translate('data.advancedSettings.dataFrameHydrationStrategyPerQueryText', { + defaultMessage: 'Per query', + }), + perResponse: i18n.translate('data.advancedSettings.dataFrameHydrationStrategyPerResponseText', { + defaultMessage: 'Per response', + }), + advanced: i18n.translate('data.advancedSettings.dataFrameHydrationStrategyAdvancedText', { + defaultMessage: 'Advanced', + }), +}; + // We add the `en` key manually here, since that's not a real numeral locale, but the // default fallback in case the locale is not found. const numeralLanguageIds = [ @@ -690,5 +705,47 @@ export function getUiSettings(): Record> { }), schema: schema.boolean(), }, + [UI_SETTINGS.DATAFRAME_HYDRATION_STRATEGY]: { + name: i18n.translate('data.advancedSettings.dataFrameHydrationStrategyTitle', { + defaultMessage: 'Data frame hydration strategy', + }), + value: 'perSource', + options: ['perSource', 'perQuery'], + optionLabels: dataFrameHydrationStrategyOptionLabels, + type: 'select', + description: i18n.translate('data.advancedSettings.dataFrameHydrationStrategyText', { + defaultMessage: `Allows you to set how often the data frame schema is updated. +
    +
  • {perSource}: hydrates the schema when the data source changes. + For example, any time the index pattern is change the data frame schema is hydrated.
  • +
  • {perQuery}: hydrates the schema per query to the data source. + Could be expensive, but ensures the schema of the data frame fits the result set.
  • +
  • {perResponse}: hydrates the schema if the data source returns a schema. + Not Implemented.
  • +
  • {advanced}: hydrates the schema in intervals. If the schema hasn't changed the interval increases. + If the schema has changed the interval resets. Not Implemented.
  • +
`, + values: { + perSource: dataFrameHydrationStrategyOptionLabels.perSource, + perQuery: dataFrameHydrationStrategyOptionLabels.perQuery, + perResponse: dataFrameHydrationStrategyOptionLabels.perResponse, + advanced: dataFrameHydrationStrategyOptionLabels.advanced, + }, + }), + category: ['search'], + schema: schema.string(), + }, + [UI_SETTINGS.QUERY_DATA_SOURCE_READONLY]: { + name: i18n.translate('data.advancedSettings.query.dataSourceReadOnlyTitle', { + defaultMessage: 'Read-only data source in query bar', + }), + value: true, + description: i18n.translate('data.advancedSettings.query.dataSourceReadOnlyText', { + defaultMessage: + 'When enabled, the global search bar prevents modifying the data source in the query input. ' + + '
Experimental: Setting to false enables modifying the data source.', + }), + schema: schema.boolean(), + }, }; } diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx index ba80e719491f..496d80703d7c 100644 --- a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx @@ -80,8 +80,8 @@ export function computeVisibleColumns( const timeFieldName = idxPattern.timeFieldName; let visibleColumnNames = columnNames; - if (displayTimeColumn && !columnNames.includes(timeFieldName)) { - visibleColumnNames = [timeFieldName, ...columnNames]; + if (displayTimeColumn && !columnNames.includes(timeFieldName!)) { + visibleColumnNames = [timeFieldName!, ...columnNames]; } return visibleColumnNames; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_data_frame.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_data_frame.tsx new file mode 100644 index 000000000000..26260635a303 --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_data_frame.tsx @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; + +export interface Props { + onCreateIndexPattern: () => void; + onNormalizeIndexPattern: () => void; +} + +export function DiscoverFieldDataFrame({ onCreateIndexPattern, onNormalizeIndexPattern }: Props) { + return ( + + + + {i18n.translate('discover.fieldChooser.dataFrame.normalizeIndexPattern', { + defaultMessage: 'Normalize', + })} + + + + + {i18n.translate('discover.fieldChooser.dataFrame.createIndexPattern', { + defaultMessage: 'Create index pattern', + })} + + + + ); +} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx index 6fee8dde6b60..ed5c8b1773f2 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx @@ -98,6 +98,8 @@ function getCompProps(): DiscoverSidebarProps { onAddFilter: jest.fn(), onAddField: jest.fn(), onRemoveField: jest.fn(), + onNormalize: jest.fn(), + onCreateIndexPattern: jest.fn(), selectedIndexPattern: indexPattern, onReorderFields: jest.fn(), }; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 9dcb5bf337cb..9d69f756f0e4 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -43,6 +43,7 @@ import { import { I18nProvider } from '@osd/i18n/react'; import { DiscoverField } from './discover_field'; import { DiscoverFieldSearch } from './discover_field_search'; +import { DiscoverFieldDataFrame } from './discover_field_data_frame'; import { FIELDS_LIMIT_SETTING } from '../../../../common'; import { groupFields } from './lib/group_fields'; import { IndexPatternField, IndexPattern, UI_SETTINGS } from '../../../../../data/public'; @@ -51,6 +52,7 @@ import { getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter'; import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { getServices } from '../../../opensearch_dashboards_services'; import { FieldDetails } from './types'; +import { displayIndexPatternCreation } from './lib/display_index_pattern_creation'; export interface DiscoverSidebarProps { /** @@ -82,6 +84,14 @@ export interface DiscoverSidebarProps { * @param fieldName */ onRemoveField: (fieldName: string) => void; + /** + * Callback function to create an index pattern + */ + onCreateIndexPattern: () => void; + /** + * Callback function to normalize the index pattern + */ + onNormalize: () => void; /** * Currently selected index pattern */ @@ -89,7 +99,16 @@ export interface DiscoverSidebarProps { } export function DiscoverSidebar(props: DiscoverSidebarProps) { - const { columns, fieldCounts, hits, onAddField, onReorderFields, selectedIndexPattern } = props; + const { + columns, + fieldCounts, + hits, + onAddField, + onReorderFields, + onNormalize, + onCreateIndexPattern, + selectedIndexPattern, + } = props; const [fields, setFields] = useState(null); const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); const services = useMemo(() => getServices(), []); @@ -99,6 +118,12 @@ export function DiscoverSidebar(props: DiscoverSidebarProps) { setFields(newFields); }, [selectedIndexPattern, fieldCounts, hits, services]); + const onNormalizeIndexPattern = useCallback(async () => { + await onNormalize(); + const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts); + setFields(newFields); + }, [fieldCounts, onNormalize, selectedIndexPattern]); + const onChangeFieldSearch = useCallback( (field: string, value: string | boolean | undefined) => { const newState = setFieldFilterProp(fieldFilterState, field, value); @@ -195,6 +220,14 @@ export function DiscoverSidebar(props: DiscoverSidebarProps) { types={fieldTypes} /> + {displayIndexPatternCreation(selectedIndexPattern) ? ( + + + + ) : null} {fields.length > 0 && ( <> diff --git a/src/plugins/discover/public/application/components/sidebar/lib/display_index_pattern_creation.tsx b/src/plugins/discover/public/application/components/sidebar/lib/display_index_pattern_creation.tsx new file mode 100644 index 000000000000..e7dccc08ef37 --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/lib/display_index_pattern_creation.tsx @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IndexPattern, DATA_FRAME_TYPES } from '../../../../../../data/public'; + +/** + * if we should display index pattern creation in the sidebar + */ +export function displayIndexPatternCreation(indexPattern: IndexPattern | undefined): boolean { + if (!indexPattern || !indexPattern.type || !indexPattern.id) return false; + return ( + Object.values(DATA_FRAME_TYPES).includes(indexPattern.type as DATA_FRAME_TYPES) && + Object.values(DATA_FRAME_TYPES).includes(indexPattern.id as DATA_FRAME_TYPES) + ); +} diff --git a/src/plugins/discover/public/application/helpers/get_data_set.ts b/src/plugins/discover/public/application/helpers/get_data_set.ts new file mode 100644 index 000000000000..b0431ac31c1e --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_data_set.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IndexPattern, IndexPatternsContract } from '../../../../data/public'; +import { SearchData } from '../view_components/utils/use_search'; + +function getDataSet( + indexPattern: IndexPattern | undefined, + state: SearchData, + indexPatternsService: IndexPatternsContract +) { + if (!indexPattern) { + return; + } + return ( + (state.title && + state.title !== indexPattern?.title && + indexPatternsService.getByTitle(state.title!, true)) || + indexPattern + ); +} + +export { getDataSet }; diff --git a/src/plugins/discover/public/application/view_components/panel/index.tsx b/src/plugins/discover/public/application/view_components/panel/index.tsx index 6b4cd2a87c91..fbe180f7e316 100644 --- a/src/plugins/discover/public/application/view_components/panel/index.tsx +++ b/src/plugins/discover/public/application/view_components/panel/index.tsx @@ -20,6 +20,7 @@ import { IndexPatternField, opensearchFilters } from '../../../../../data/public import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; import { DiscoverViewServices } from '../../../build_services'; import { popularizeField } from '../../helpers/popularize_field'; +import { getDataSet } from '../../helpers/get_data_set'; import { buildColumns } from '../../utils/columns'; // eslint-disable-next-line import/no-default-export @@ -31,6 +32,7 @@ export default function DiscoverPanel(props: ViewProps) { }, capabilities, indexPatterns, + application, } = services; const { data$, indexPattern } = useDiscoverContext(); const [fetchState, setFetchState] = useState(data$.getValue()); @@ -86,14 +88,30 @@ export default function DiscoverPanel(props: ViewProps) { [filterManager, indexPattern] ); + const onCreateIndexPattern = useCallback(async () => { + if (!fetchState.title) return; + if (fetchState.title === indexPattern?.title) return; + application?.navigateToApp('management', { + path: `opensearch-dashboards/indexPatterns/create?id=${fetchState.title}`, + }); + }, [application, fetchState.title, indexPattern?.title]); + + const onNormalize = useCallback(async () => { + if (!fetchState.title) return; + if (fetchState.title === indexPattern?.title) return; + const dataSet = getDataSet(indexPattern, fetchState, indexPatterns); + await indexPatterns.refreshFields(dataSet!, true); + }, [fetchState, indexPattern, indexPatterns]); + return ( { - if (indexPattern && capabilities.discover?.save) { - popularizeField(indexPattern, fieldName, indexPatterns); + const dataSet = getDataSet(indexPattern, fetchState, indexPatterns); + if (dataSet && capabilities.discover?.save) { + popularizeField(dataSet, fieldName, indexPatterns); } dispatch( @@ -104,8 +122,9 @@ export default function DiscoverPanel(props: ViewProps) { ); }} onRemoveField={(fieldName) => { - if (indexPattern && capabilities.discover?.save) { - popularizeField(indexPattern, fieldName, indexPatterns); + const dataSet = getDataSet(indexPattern, fetchState, indexPatterns); + if (dataSet && capabilities.discover?.save) { + popularizeField(dataSet, fieldName, indexPatterns); } dispatch(removeColumn(fieldName)); @@ -118,7 +137,9 @@ export default function DiscoverPanel(props: ViewProps) { }) ); }} - selectedIndexPattern={indexPattern} + selectedIndexPattern={getDataSet(indexPattern, fetchState, indexPatterns)} + onCreateIndexPattern={onCreateIndexPattern} + onNormalize={onNormalize} onAddFilter={onAddFilter} /> ); diff --git a/src/plugins/discover/public/application/view_components/utils/update_search_source.ts b/src/plugins/discover/public/application/view_components/utils/update_search_source.ts index 1404773eb9d4..a8480fdad18a 100644 --- a/src/plugins/discover/public/application/view_components/utils/update_search_source.ts +++ b/src/plugins/discover/public/application/view_components/utils/update_search_source.ts @@ -30,9 +30,22 @@ export const updateSearchSource = async ({ histogramConfigs, }: Props) => { const { uiSettings, data } = services; + let dataSet = indexPattern; + const dataFrame = searchSource?.getDataFrame(); + if ( + searchSource && + dataFrame && + dataFrame.name && + dataFrame.name !== '' && + dataSet.title !== dataFrame.name + ) { + dataSet = data.indexPatterns.getByTitle(dataFrame.name, true) ?? dataSet; + searchSource.setField('index', dataSet); + } + const sortForSearchSource = getSortForSearchSource( sort, - indexPattern, + dataSet, uiSettings.get(SORT_DEFAULT_ORDER_SETTING) ); const size = uiSettings.get(SAMPLE_SIZE_SETTING); @@ -43,18 +56,18 @@ export const updateSearchSource = async ({ // searchSource which applies time range const timeRangeSearchSource = await data.search.searchSource.create(); const { isDefault } = indexPatternUtils; - if (isDefault(indexPattern)) { + if (isDefault(dataSet)) { const timefilter = data.query.timefilter.timefilter; timeRangeSearchSource.setField('filter', () => { - return timefilter.createFilter(indexPattern); + return timefilter.createFilter(dataSet); }); } searchSourceInstance.setParent(timeRangeSearchSource); searchSourceInstance.setFields({ - index: indexPattern, + index: dataSet, sort: sortForSearchSource, size, query: data.query.queryString.getQuery() || null, diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts index 06eabb1e139f..9da9704f32a7 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_search.ts +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -47,6 +47,7 @@ export interface SearchData { rows?: OpenSearchSearchHit[]; bucketInterval?: TimechartHeaderBucketInterval | {}; chartData?: Chart; + title?: string; } export type SearchRefetch = 'refetch' | undefined; @@ -105,7 +106,8 @@ export const useSearch = (services: DiscoverViewServices) => { const refetch$ = useMemo(() => new Subject(), []); const fetch = useCallback(async () => { - if (!indexPattern) { + let dataSet = indexPattern; + if (!dataSet) { data$.next({ status: shouldSearchOnPageLoad() ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, }); @@ -122,17 +124,19 @@ export const useSearch = (services: DiscoverViewServices) => { // Abort any in-progress requests before fetching again if (fetchStateRef.current.abortController) fetchStateRef.current.abortController.abort(); fetchStateRef.current.abortController = new AbortController(); - const histogramConfigs = indexPattern.timeFieldName - ? createHistogramConfigs(indexPattern, interval || 'auto', data) + const histogramConfigs = dataSet.timeFieldName + ? createHistogramConfigs(dataSet, interval || 'auto', data) : undefined; const searchSource = await updateSearchSource({ - indexPattern, + indexPattern: dataSet, services, sort, searchSource: savedSearch?.searchSource, histogramConfigs, }); + dataSet = searchSource.getField('index'); + try { // Only show loading indicator if we are fetching when the rows are empty if (fetchStateRef.current.rows?.length === 0) { @@ -149,7 +153,7 @@ export const useSearch = (services: DiscoverViewServices) => { }); const inspectorRequest = inspectorAdapters.requests.start(title, { description }); inspectorRequest.stats(getRequestInspectorStats(searchSource)); - searchSource.getSearchRequestBody().then((body) => { + searchSource.getSearchRequestBody().then((body: object) => { inspectorRequest.json(body); }); @@ -167,7 +171,7 @@ export const useSearch = (services: DiscoverViewServices) => { let bucketInterval = {}; let chartData; for (const row of rows) { - const fields = Object.keys(indexPattern.flattenHit(row)); + const fields = Object.keys(dataSet!.flattenHit(row)); for (const fieldName of fields) { fetchStateRef.current.fieldCounts[fieldName] = (fetchStateRef.current.fieldCounts[fieldName] || 0) + 1; @@ -196,6 +200,10 @@ export const useSearch = (services: DiscoverViewServices) => { rows, bucketInterval, chartData, + title: + indexPattern?.title !== searchSource.getDataFrame()?.name + ? searchSource.getDataFrame()?.name + : indexPattern?.title, }); } catch (error) { // If the request was aborted then no need to surface this error in the UI diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index f8e0f254f925..8b46889a8e36 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -202,6 +202,7 @@ export class DiscoverPlugin generateCb: (renderProps: any) => { const globalFilters: any = getServices().filterManager.getGlobalFilters(); const appFilters: any = getServices().filterManager.getAppFilters(); + const showDocLinks = getServices().data.ui.Settings.getUiOverrides().showDocLinks; const hash = stringify( url.encodeQuery({ @@ -222,7 +223,9 @@ export class DiscoverPlugin return { url: generateDocViewsUrl(contextUrl), - hide: !renderProps.indexPattern.isTimeBased(), + hide: + (showDocLinks !== undefined ? !showDocLinks : false) || + !renderProps.indexPattern.isTimeBased(), }; }, order: 1, @@ -233,11 +236,15 @@ export class DiscoverPlugin defaultMessage: 'View single document', }), generateCb: (renderProps) => { + const showDocLinks = getServices().data.ui.Settings.getUiOverrides().showDocLinks; + const docUrl = `#/doc/${renderProps.indexPattern.id}/${ renderProps.hit._index }?id=${encodeURIComponent(renderProps.hit._id)}`; + return { url: generateDocViewsUrl(docUrl), + hide: showDocLinks !== undefined ? !showDocLinks : false, }; }, order: 2, diff --git a/src/plugins/home/server/services/sample_data/data_sets/index.ts b/src/plugins/home/server/services/sample_data/data_sets/index.ts index a75115950f93..dc9ac8ae3711 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/index.ts @@ -31,3 +31,4 @@ export { flightsSpecProvider } from './flights'; export { logsSpecProvider } from './logs'; export { ecommerceSpecProvider } from './ecommerce'; +export { appendDataSourceId, getSavedObjectsWithDataSource } from './util'; diff --git a/src/plugins/home/server/services/sample_data/lib/sample_dataset_schema.ts b/src/plugins/home/server/services/sample_data/lib/sample_dataset_schema.ts index 1e2951c596b9..c202290f911a 100644 --- a/src/plugins/home/server/services/sample_data/lib/sample_dataset_schema.ts +++ b/src/plugins/home/server/services/sample_data/lib/sample_dataset_schema.ts @@ -75,13 +75,19 @@ export const sampleDataSchema = { // saved object id of main dashboard for sample data set overviewDashboard: Joi.string().required(), + getDataSourceIntegratedDashboard: Joi.func().required(), appLinks: Joi.array().items(appLinkSchema).default([]), // saved object id of default index-pattern for sample data set defaultIndex: Joi.string().required(), + getDataSourceIntegratedDefaultIndex: Joi.func().required(), // OpenSearch Dashboards saved objects (index patter, visualizations, dashboard, ...) // Should provide a nice demo of OpenSearch Dashboards's functionality with the sample data set savedObjects: Joi.array().items(Joi.object()).required(), + getDataSourceIntegratedSavedObjects: Joi.func().required(), dataIndices: Joi.array().items(dataIndexSchema).required(), + + status: Joi.string(), + statusMsg: Joi.any(), }; diff --git a/src/plugins/vis_type_vega/public/data_model/opensearch_query_parser.ts b/src/plugins/vis_type_vega/public/data_model/opensearch_query_parser.ts index 8f5594863903..2bbd0159316c 100644 --- a/src/plugins/vis_type_vega/public/data_model/opensearch_query_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/opensearch_query_parser.ts @@ -207,7 +207,7 @@ export class OpenSearchQueryParser { if (context) { // Use dashboard context const newQuery = cloneDeep(this._filters); - if (timefield) { + if (timefield && newQuery.type !== 'unsupported') { newQuery.bool!.must!.push(body.query); } body.query = newQuery; From 368a17ba5364838d3fa0449b5eebd2b0c731e9e8 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 10:44:30 -0700 Subject: [PATCH 5/9] [MDS] Modify toast + popover warning to include incompatible datasources (#6678) (#6746) * Fix merge conflict * Refactor to incompatibleDataSourcesExist * Changeset file for PR #6678 created/updated * Move required args to the top --------- (cherry picked from commit bce682778509865ef17923833879d82bc218df69) Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/6678.yml | 2 + .../public/components/constants.tsx | 5 ++ .../data_source_aggregated_view.test.tsx | 73 +++++++++++++++- .../data_source_aggregated_view.tsx | 24 ++++-- .../data_source_multi_selectable.tsx | 19 +++-- .../data_source_selectable.test.tsx | 81 ++++++++++++++---- .../data_source_selectable.tsx | 28 +++--- .../no_data_source.test.tsx.snap | 85 ++++++++++++++++++- .../no_data_source/no_data_source.test.tsx | 32 +++++-- .../no_data_source/no_data_source.tsx | 24 +++++- .../public/components/utils.test.ts | 48 +++++++++-- .../public/components/utils.ts | 31 +++++-- 12 files changed, 379 insertions(+), 73 deletions(-) create mode 100644 changelogs/fragments/6678.yml diff --git a/changelogs/fragments/6678.yml b/changelogs/fragments/6678.yml new file mode 100644 index 000000000000..46e9568f5653 --- /dev/null +++ b/changelogs/fragments/6678.yml @@ -0,0 +1,2 @@ +fix: +- [MDS] Add a new message to data source components when there are no compatible datasources ([#6678](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6678)) \ No newline at end of file diff --git a/src/plugins/data_source_management/public/components/constants.tsx b/src/plugins/data_source_management/public/components/constants.tsx index 0d22aed50179..720e2d175867 100644 --- a/src/plugins/data_source_management/public/components/constants.tsx +++ b/src/plugins/data_source_management/public/components/constants.tsx @@ -12,3 +12,8 @@ export const LocalCluster: DataSourceOption = { }), id: '', }; + +export const NO_DATASOURCES_CONNECTED_MESSAGE = 'No data sources connected yet.'; +export const CONNECT_DATASOURCES_MESSAGE = 'Connect your data sources to get started.'; +export const NO_COMPATIBLE_DATASOURCES_MESSAGE = 'No compatible data sources are available.'; +export const ADD_COMPATIBLE_DATASOURCES_MESSAGE = 'Add a compatible data source.'; diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx index f7493ff843be..43b22ed219ee 100644 --- a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx @@ -5,8 +5,9 @@ import { ShallowWrapper, shallow } from 'enzyme'; import React from 'react'; +import { i18n } from '@osd/i18n'; import { DataSourceAggregatedView } from './data_source_aggregated_view'; -import { SavedObject, SavedObjectsClientContract } from '../../../../../core/public'; +import { IToasts, SavedObject, SavedObjectsClientContract } from '../../../../../core/public'; import { applicationServiceMock, notificationServiceMock, @@ -21,6 +22,12 @@ import { import * as utils from '../utils'; import { EuiSelectable, EuiSwitch } from '@elastic/eui'; import { DataSourceAttributes } from '../../types'; +import { + ADD_COMPATIBLE_DATASOURCES_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + NO_DATASOURCES_CONNECTED_MESSAGE, +} from '../constants'; describe('DataSourceAggregatedView: read all view (displayAllCompatibleDataSources is set to true)', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -409,6 +416,7 @@ describe('DataSourceAggregatedView empty state test due to filter out with local dataSourceFilter={filter} /> ); + const noCompatibleDataSourcesMessage = `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}`; expect(component).toMatchSnapshot(); await nextTick(); @@ -416,7 +424,7 @@ describe('DataSourceAggregatedView empty state test due to filter out with local expect(toasts.add.mock.calls[0][0]).toEqual({ color: 'warning', text: expect.any(Function), - title: 'No data sources connected yet. Connect your data sources to get started.', + title: noCompatibleDataSourcesMessage, }); expect(component.state('showEmptyState')).toBe(true); await nextTick(); @@ -502,3 +510,64 @@ describe('DataSourceAggregatedView error state test no matter hide local cluster } ); }); + +describe('DataSourceAggregatedView warning messages', () => { + const client = {} as any; + const uiSettings = uiSettingsServiceMock.createStartContract(); + const nextTick = () => new Promise((res) => process.nextTick(res)); + let toasts: IToasts; + const noDataSourcesConnectedMessage = `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; + const noCompatibleDataSourcesMessage = `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}`; + + beforeEach(() => { + toasts = notificationServiceMock.createStartContract().toasts; + mockUiSettingsCalls(uiSettings, 'get', 'test1'); + }); + + it.each([ + { + findFunc: jest.fn().mockResolvedValue(getDataSourcesWithFieldsResponse), + defaultMessage: noCompatibleDataSourcesMessage, + activeDataSourceIds: ['test2'], + }, + { + findFunc: jest.fn().mockResolvedValue({ savedObjects: [] }), + defaultMessage: noDataSourcesConnectedMessage, + activeDataSourceIds: ['test2'], + }, + { + findFunc: jest.fn().mockResolvedValue(getDataSourcesWithFieldsResponse), + defaultMessage: noCompatibleDataSourcesMessage, + activeDataSourceIds: undefined, + }, + { + findFunc: jest.fn().mockResolvedValue({ savedObjects: [] }), + defaultMessage: noDataSourcesConnectedMessage, + activeDataSourceIds: undefined, + }, + ])( + 'should display correct warning message when no datasource selections are available and local cluster is hidden', + async ({ findFunc, defaultMessage, activeDataSourceIds }) => { + client.find = findFunc; + shallow( + false} + uiSettings={uiSettings} + /> + ); + await nextTick(); + + expect(toasts.add).toBeCalledWith( + expect.objectContaining({ + title: i18n.translate('dataSource.noAvailableDataSourceError', { defaultMessage }), + }) + ); + } + ); +}); diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx index 00eacb2ea844..8d8cd2ed0ef9 100644 --- a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx @@ -45,6 +45,7 @@ interface DataSourceAggregatedViewState extends DataSourceBaseState { allDataSourcesIdToTitleMap: Map; switchChecked: boolean; defaultDataSource: string | null; + incompatibleDataSourcesExist: boolean; } interface DataSourceOptionDisplay extends DataSourceOption { @@ -68,6 +69,7 @@ export class DataSourceAggregatedView extends React.Component< showError: false, switchChecked: false, defaultDataSource: null, + incompatibleDataSourcesExist: false, }; } @@ -113,11 +115,12 @@ export class DataSourceAggregatedView extends React.Component< } if (allDataSourcesIdToTitleMap.size === 0) { - handleNoAvailableDataSourceError( - this.onEmptyState.bind(this), - this.props.notifications, - this.props.application - ); + handleNoAvailableDataSourceError({ + changeState: this.onEmptyState.bind(this, !!fetchedDataSources?.length), + notifications: this.props.notifications, + application: this.props.application, + incompatibleDataSourcesExist: !!fetchedDataSources?.length, + }); return; } @@ -133,8 +136,8 @@ export class DataSourceAggregatedView extends React.Component< }); } - onEmptyState() { - this.setState({ showEmptyState: true }); + onEmptyState(incompatibleDataSourcesExist: boolean) { + this.setState({ showEmptyState: true, incompatibleDataSourcesExist }); } onError() { @@ -143,7 +146,12 @@ export class DataSourceAggregatedView extends React.Component< render() { if (this.state.showEmptyState) { - return ; + return ( + + ); } if (this.state.showError) { return ; diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx index 85506ec84b61..481df093d741 100644 --- a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx @@ -34,6 +34,7 @@ interface DataSourceMultiSeletableState extends DataSourceBaseState { dataSourceOptions: SelectedDataSourceOption[]; selectedOptions: SelectedDataSourceOption[]; defaultDataSource: string | null; + incompatibleDataSourcesExist: boolean; } export class DataSourceMultiSelectable extends React.Component< @@ -51,6 +52,7 @@ export class DataSourceMultiSelectable extends React.Component< defaultDataSource: null, showEmptyState: false, showError: false, + incompatibleDataSourcesExist: false, }; } @@ -90,12 +92,13 @@ export class DataSourceMultiSelectable extends React.Component< if (!this._isMounted) return; if (selectedOptions.length === 0) { - handleNoAvailableDataSourceError( - this.onEmptyState.bind(this), - this.props.notifications, - this.props.application, - this.props.onSelectedDataSources - ); + handleNoAvailableDataSourceError({ + changeState: this.onEmptyState.bind(this, !!fetchedDataSources?.length), + notifications: this.props.notifications, + application: this.props.application, + callback: this.props.onSelectedDataSources, + incompatibleDataSourcesExist: !!fetchedDataSources?.length, + }); return; } @@ -115,8 +118,8 @@ export class DataSourceMultiSelectable extends React.Component< } } - onEmptyState() { - this.setState({ showEmptyState: true }); + onEmptyState(incompatibleDataSourcesExist: boolean) { + this.setState({ showEmptyState: true, incompatibleDataSourcesExist }); } onError() { diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.test.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.test.tsx index f1cef722c3c1..6521aaddb258 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.test.tsx @@ -4,6 +4,7 @@ */ import { ShallowWrapper, shallow, mount } from 'enzyme'; +import { i18n } from '@osd/i18n'; import { SavedObjectsClientContract } from '../../../../../core/public'; import { notificationServiceMock } from '../../../../../core/public/mocks'; import React from 'react'; @@ -12,6 +13,12 @@ import { AuthType } from '../../types'; import { getDataSourcesWithFieldsResponse, mockResponseForSavedObjectsCalls } from '../../mocks'; import { render } from '@testing-library/react'; import * as utils from '../utils'; +import { + NO_DATASOURCES_CONNECTED_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + ADD_COMPATIBLE_DATASOURCES_MESSAGE, +} from '../constants'; describe('DataSourceSelectable', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -19,6 +26,8 @@ describe('DataSourceSelectable', () => { let client: SavedObjectsClientContract; const { toasts } = notificationServiceMock.createStartContract(); const nextTick = () => new Promise((res) => process.nextTick(res)); + const noDataSourcesConnectedMessage = `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; + const noCompatibleDataSourcesMessage = `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}`; beforeEach(() => { client = { @@ -145,6 +154,7 @@ describe('DataSourceSelectable', () => { }, ], showError: false, + incompatibleDataSourcesExist: false, }); containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); @@ -167,6 +177,7 @@ describe('DataSourceSelectable', () => { }, ], showError: false, + incompatibleDataSourcesExist: false, }); expect(onSelectedDataSource).toBeCalledWith([{ id: 'test2', label: 'test2' }]); @@ -345,6 +356,7 @@ describe('DataSourceSelectable', () => { }, ], showError: false, + incompatibleDataSourcesExist: false, }); }); @@ -374,6 +386,7 @@ describe('DataSourceSelectable', () => { selectedOption: [], showEmptyState: false, showError: true, + incompatibleDataSourcesExist: false, }); containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); @@ -396,27 +409,59 @@ describe('DataSourceSelectable', () => { }, ], showError: true, + incompatibleDataSourcesExist: false, }); expect(onSelectedDataSource).toBeCalledWith([{ id: 'test2', label: 'test2' }]); expect(onSelectedDataSource).toHaveBeenCalled(); }); - it('should render no data source when no data source filtered out and hide local cluster', async () => { - const onSelectedDataSource = jest.fn(); - render( - false} - /> - ); - await nextTick(); - expect(toasts.add).toBeCalled(); - expect(onSelectedDataSource).toBeCalledWith([]); - }); + + it.each([ + { + findFunc: jest.fn().mockResolvedValue({ savedObjects: [] }), + defaultMessage: noDataSourcesConnectedMessage, + selectedOption: undefined, + }, + { + findFunc: jest.fn().mockResolvedValue({ savedObjects: [] }), + defaultMessage: noDataSourcesConnectedMessage, + selectedOption: [{ id: 'test2' }], + }, + { + findFunc: jest.fn().mockResolvedValue(getDataSourcesWithFieldsResponse), + defaultMessage: noCompatibleDataSourcesMessage, + selectedOption: undefined, + }, + { + findFunc: jest.fn().mockResolvedValue(getDataSourcesWithFieldsResponse), + defaultMessage: noCompatibleDataSourcesMessage, + selectedOption: [{ id: 'test2' }], + }, + ])( + 'should render correct message when there are no datasource options available and local cluster is hidden', + async ({ findFunc, selectedOption, defaultMessage }) => { + client.find = findFunc; + const onSelectedDataSource = jest.fn(); + render( + false} + /> + ); + await nextTick(); + + expect(toasts.add).toBeCalledWith( + expect.objectContaining({ + title: i18n.translate('dataSource.noAvailableDataSourceError', { defaultMessage }), + }) + ); + expect(onSelectedDataSource).toBeCalledWith([]); + } + ); }); diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx index 63b9eebf26c1..fd1c685676f0 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx @@ -54,8 +54,9 @@ interface DataSourceSelectableProps { interface DataSourceSelectableState extends DataSourceBaseState { dataSourceOptions: DataSourceOption[]; isPopoverOpen: boolean; - selectedOption?: DataSourceOption[]; defaultDataSource: string | null; + incompatibleDataSourcesExist: boolean; + selectedOption?: DataSourceOption[]; } export class DataSourceSelectable extends React.Component< @@ -74,6 +75,7 @@ export class DataSourceSelectable extends React.Component< defaultDataSource: null, showEmptyState: false, showError: false, + incompatibleDataSourcesExist: false, }; this.onChange.bind(this); @@ -187,12 +189,13 @@ export class DataSourceSelectable extends React.Component< } if (dataSourceOptions.length === 0) { - handleNoAvailableDataSourceError( - this.onEmptyState.bind(this), - this.props.notifications, - this.props.application, - this.props.onSelectedDataSources - ); + handleNoAvailableDataSourceError({ + changeState: this.onEmptyState.bind(this, !!fetchedDataSources?.length), + notifications: this.props.notifications, + application: this.props.application, + callback: this.props.onSelectedDataSources, + incompatibleDataSourcesExist: !!fetchedDataSources?.length, + }); return; } @@ -214,8 +217,8 @@ export class DataSourceSelectable extends React.Component< } } - onEmptyState() { - this.setState({ showEmptyState: true }); + onEmptyState(incompatibleDataSourcesExist: boolean) { + this.setState({ showEmptyState: true, incompatibleDataSourcesExist }); } onError() { @@ -242,7 +245,12 @@ export class DataSourceSelectable extends React.Component< render() { if (this.state.showEmptyState) { - return ; + return ( + + ); } if (this.state.showError) { diff --git a/src/plugins/data_source_management/public/components/no_data_source/__snapshots__/no_data_source.test.tsx.snap b/src/plugins/data_source_management/public/components/no_data_source/__snapshots__/no_data_source.test.tsx.snap index ee8f2120012f..f7f3e41b9cb5 100644 --- a/src/plugins/data_source_management/public/components/no_data_source/__snapshots__/no_data_source.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/no_data_source/__snapshots__/no_data_source.test.tsx.snap @@ -83,7 +83,90 @@ exports[`NoDataSource should render correctly with the provided totalDataSourceC
`; -exports[`NoDataSource should render normally 1`] = ` +exports[`NoDataSource should render normally when incompatibleDataSourcesExist is %b 1`] = ` + + } + closePopover={[Function]} + data-test-subj="dataSourceEmptyStatePopover" + display="inlineBlock" + hasArrow={true} + id="dataSourceEmptyStatePopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" +> + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`NoDataSource should render normally when incompatibleDataSourcesExist is %b 2`] = ` { const nextTick = () => new Promise((res) => process.nextTick(res)); it('should render correctly with the provided totalDataSourceCount', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper).toMatchSnapshot(); }); it('should display popover when click "No data sources" button', async () => { const applicationMock = coreMock.createStart().application; const container = render( - + ); await nextTick(); @@ -39,7 +45,11 @@ describe('NoDataSource', () => { const navigateToAppMock = applicationMock.navigateToApp; const container = render( - + ); await nextTick(); @@ -55,8 +65,16 @@ describe('NoDataSource', () => { }); }); - it('should render normally', () => { - component = shallow(); - expect(component).toMatchSnapshot(); - }); + it.each([false, true])( + 'should render normally when incompatibleDataSourcesExist is %b', + (incompatibleDataSourcesExist) => { + component = shallow( + + ); + expect(component).toMatchSnapshot(); + } + ); }); diff --git a/src/plugins/data_source_management/public/components/no_data_source/no_data_source.tsx b/src/plugins/data_source_management/public/components/no_data_source/no_data_source.tsx index d10efe8c4a7b..6b48a7a7a0b4 100644 --- a/src/plugins/data_source_management/public/components/no_data_source/no_data_source.tsx +++ b/src/plugins/data_source_management/public/components/no_data_source/no_data_source.tsx @@ -20,12 +20,22 @@ import { FormattedMessage } from 'react-intl'; import { DataSourceDropDownHeader } from '../drop_down_header'; import { DSM_APP_ID } from '../../plugin'; import { EmptyIcon } from '../custom_database_icon'; +import { + ADD_COMPATIBLE_DATASOURCES_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + NO_DATASOURCES_CONNECTED_MESSAGE, +} from '../constants'; interface DataSourceDropDownHeaderProps { + incompatibleDataSourcesExist: boolean; application?: ApplicationStart; } -export const NoDataSource: React.FC = ({ application }) => { +export const NoDataSource: React.FC = ({ + application, + incompatibleDataSourcesExist, +}) => { const [showPopover, setShowPopover] = useState(false); const button = ( = ({ applicat { } @@ -72,7 +86,11 @@ export const NoDataSource: React.FC = ({ applicat { } diff --git a/src/plugins/data_source_management/public/components/utils.test.ts b/src/plugins/data_source_management/public/components/utils.test.ts index b2628e3d3062..7e842345812e 100644 --- a/src/plugins/data_source_management/public/components/utils.test.ts +++ b/src/plugins/data_source_management/public/components/utils.test.ts @@ -42,10 +42,17 @@ import { sigV4AuthMethod, usernamePasswordAuthMethod, } from '../types'; -import { HttpStart, SavedObject } from 'opensearch-dashboards/public'; +import { HttpStart, IToasts, SavedObject } from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; import { AuthenticationMethod, AuthenticationMethodRegistry } from '../auth_registry'; import { deepEqual } from 'assert'; import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; +import { + ADD_COMPATIBLE_DATASOURCES_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + NO_DATASOURCES_CONNECTED_MESSAGE, +} from './constants'; const { savedObjects } = coreMock.createStart(); const { uiSettings } = coreMock.createStart(); @@ -84,13 +91,40 @@ describe('DataSourceManagement: Utils.ts', () => { }); describe('Handle no available data source error', () => { - const { toasts } = notificationServiceMock.createStartContract(); + let toasts: IToasts; + const noDataSourcesConnectedMessage = `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; + const noCompatibleDataSourcesMessage = `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}`; - test('should send warning when data source is not available', () => { - const changeState = jest.fn(); - handleNoAvailableDataSourceError(changeState, toasts); - expect(toasts.add).toBeCalledTimes(1); - }); + beforeEach(() => { + toasts = notificationServiceMock.createStartContract().toasts; + }); + + test.each([ + { + incompatibleDataSourcesExist: false, + defaultMessage: noDataSourcesConnectedMessage, + }, + { + incompatibleDataSourcesExist: true, + defaultMessage: noCompatibleDataSourcesMessage, + }, + ])( + 'should send warning when data source is not available', + ({ incompatibleDataSourcesExist, defaultMessage }) => { + const changeState = jest.fn(); + handleNoAvailableDataSourceError({ + changeState, + notifications: toasts, + incompatibleDataSourcesExist, + }); + expect(toasts.add).toBeCalledTimes(1); + expect(toasts.add).toBeCalledWith( + expect.objectContaining({ + title: i18n.translate('dataSource.noAvailableDataSourceError', { defaultMessage }), + }) + ); + } + ); }); describe('Get data source by ID', () => { diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index 8f635f840aec..7a4ec06d5cfb 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -25,6 +25,12 @@ import { DataSourceGroupLabelOption } from './data_source_menu/types'; import { createGetterSetter } from '../../../opensearch_dashboards_utils/public'; import { toMountPoint } from '../../../opensearch_dashboards_react/public'; import { getManageDataSourceButton, getReloadButton } from './toast_button'; +import { + ADD_COMPATIBLE_DATASOURCES_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + NO_DATASOURCES_CONNECTED_MESSAGE, +} from './constants'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -87,18 +93,25 @@ export async function setFirstDataSourceAsDefault( } } -export function handleNoAvailableDataSourceError( - changeState: () => void, - notifications: ToastsStart, - application?: ApplicationStart, - callback?: (ds: DataSourceOption[]) => void -) { +export interface HandleNoAvailableDataSourceErrorProps { + changeState: () => void; + notifications: ToastsStart; + incompatibleDataSourcesExist: boolean; + application?: ApplicationStart; + callback?: (ds: DataSourceOption[]) => void; +} + +export function handleNoAvailableDataSourceError(props: HandleNoAvailableDataSourceErrorProps) { + const { changeState, notifications, application, callback, incompatibleDataSourcesExist } = props; + + const defaultMessage = incompatibleDataSourcesExist + ? `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}` + : `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; + changeState(); if (callback) callback([]); notifications.add({ - title: i18n.translate('dataSource.noAvailableDataSourceError', { - defaultMessage: 'No data sources connected yet. Connect your data sources to get started.', - }), + title: i18n.translate('dataSource.noAvailableDataSourceError', { defaultMessage }), text: toMountPoint(getManageDataSourceButton(application)), color: 'warning', }); From 64af4b51b399d8f68cc8ca0dfa050d128ae0eb80 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 10:44:39 -0700 Subject: [PATCH 6/9] [MDS] Support for Timeline (#6385) (#6493) * Add MDS support to Timeline Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor to function and add unit tests Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fix typo in args Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor build request to pass unit tests Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add to CHANGELOG Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor error messages + address comments Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fix ut Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Change to data source feature Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fix UT Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Address comments Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --------- Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> (cherry picked from commit 73e5d78c968f1ab20d28eed6ac6ea772f9c6b620) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md Co-authored-by: github-actions[bot] Co-authored-by: ZilongX <99905560+ZilongX@users.noreply.github.com> --- .../opensearch_dashboards.json | 1 + .../helpers/timeline_request_handler.ts | 4 +- .../server/handlers/chain_runner.js | 4 +- .../server/lib/fetch_data_source_id.test.ts | 152 ++++++++++++++++++ .../server/lib/fetch_data_source_id.ts | 42 +++++ .../vis_type_timeline/server/lib/services.ts | 10 ++ .../vis_type_timeline/server/plugin.ts | 10 +- .../vis_type_timeline/server/routes/run.ts | 5 +- .../series_functions/opensearch/index.js | 19 ++- .../opensearch/lib/build_request.js | 3 +- src/plugins/vis_type_timeline/server/types.ts | 15 ++ 11 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.test.ts create mode 100644 src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.ts create mode 100644 src/plugins/vis_type_timeline/server/lib/services.ts diff --git a/src/plugins/vis_type_timeline/opensearch_dashboards.json b/src/plugins/vis_type_timeline/opensearch_dashboards.json index 14b3af415177..c4fa1e8d40fd 100644 --- a/src/plugins/vis_type_timeline/opensearch_dashboards.json +++ b/src/plugins/vis_type_timeline/opensearch_dashboards.json @@ -4,6 +4,7 @@ "opensearchDashboardsVersion": "opensearchDashboards", "server": true, "ui": true, + "optionalPlugins": ["dataSource"], "requiredPlugins": ["visualizations", "data", "expressions"], "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "visDefaultEditor"] } diff --git a/src/plugins/vis_type_timeline/public/helpers/timeline_request_handler.ts b/src/plugins/vis_type_timeline/public/helpers/timeline_request_handler.ts index 467fef727f29..d7b955f96ef9 100644 --- a/src/plugins/vis_type_timeline/public/helpers/timeline_request_handler.ts +++ b/src/plugins/vis_type_timeline/public/helpers/timeline_request_handler.ts @@ -129,10 +129,12 @@ export function getTimelineRequestHandler({ }); } catch (e) { if (e && e.body) { + const errorTitle = + e.body.attributes && e.body.attributes.title ? ` (${e.body.attributes.title})` : ''; const err = new Error( `${i18n.translate('timeline.requestHandlerErrorTitle', { defaultMessage: 'Timeline request error', - })}: ${e.body.title} ${e.body.message}` + })}${errorTitle}: ${e.body.message}` ); err.stack = e.stack; throw err; diff --git a/src/plugins/vis_type_timeline/server/handlers/chain_runner.js b/src/plugins/vis_type_timeline/server/handlers/chain_runner.js index 75382b73de57..39af9939056f 100644 --- a/src/plugins/vis_type_timeline/server/handlers/chain_runner.js +++ b/src/plugins/vis_type_timeline/server/handlers/chain_runner.js @@ -47,7 +47,9 @@ export default function chainRunner(tlConfig) { let sheet; function throwWithCell(cell, exception) { - throw new Error(' in cell #' + (cell + 1) + ': ' + exception.message); + const e = new Error(exception.message); + e.name = `Expression parse error in cell #${cell + 1}`; + throw e; } // Invokes a modifier function, resolving arguments into series as needed diff --git a/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.test.ts b/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.test.ts new file mode 100644 index 000000000000..e5596a001a2d --- /dev/null +++ b/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import { fetchDataSourceIdByName } from './fetch_data_source_id'; +import { OpenSearchFunctionConfig } from '../types'; + +jest.mock('./services', () => ({ + getDataSourceEnabled: jest + .fn() + .mockReturnValueOnce({ enabled: false }) + .mockReturnValue({ enabled: true }), +})); + +describe('fetchDataSourceIdByName()', () => { + const validId = 'some-valid-id'; + const config: OpenSearchFunctionConfig = { + q: null, + metric: null, + split: null, + index: null, + timefield: null, + kibana: null, + opensearchDashboards: null, + interval: null, + }; + const client = savedObjectsClientMock.create(); + client.find = jest.fn().mockImplementation((props) => { + if (props.search === '"No Results With Filter"') { + return Promise.resolve({ + saved_objects: [ + { + id: 'some-non-matching-id', + attributes: { + title: 'No Results With Filter Some Suffix', + }, + }, + ], + }); + } + if (props.search === '"Duplicate Title"') { + return Promise.resolve({ + saved_objects: [ + { + id: 'duplicate-id-1', + attributes: { + title: 'Duplicate Title', + }, + }, + { + id: 'duplicate-id-2', + attributes: { + title: 'Duplicate Title', + }, + }, + ], + }); + } + if (props.search === '"Some Data Source"') { + return Promise.resolve({ + saved_objects: [ + { + id: validId, + attributes: { + title: 'Some Data Source', + }, + }, + ], + }); + } + if (props.search === '"Some Prefix"') { + return Promise.resolve({ + saved_objects: [ + { + id: 'some-id-2', + attributes: { + title: 'Some Prefix B', + }, + }, + { + id: validId, + attributes: { + title: 'Some Prefix', + }, + }, + ], + }); + } + + return Promise.resolve({ saved_objects: [] }); + }); + + it('should return undefined if data_source_name is not present', async () => { + expect(await fetchDataSourceIdByName(config, client)).toBe(undefined); + }); + + it('should return undefined if data_source_name is an empty string', async () => { + expect(await fetchDataSourceIdByName({ ...config, data_source_name: '' }, client)).toBe( + undefined + ); + }); + + it('should throw errors when MDS is disabled', async () => { + await expect( + fetchDataSourceIdByName({ ...config, data_source_name: 'Some Data Source' }, client) + ).rejects.toThrowError( + 'To query from multiple data sources, first enable the data source feature' + ); + }); + + it.each([ + { + dataSourceName: 'Non-existent Data Source', + expectedResultCount: 0, + }, + { + dataSourceName: 'No Results With Filter', + expectedResultCount: 0, + }, + { + dataSourceName: 'Duplicate Title', + expectedResultCount: 2, + }, + ])( + 'should throw errors when non-existent or duplicate data_source_name is provided', + async ({ dataSourceName, expectedResultCount }) => { + await expect( + fetchDataSourceIdByName({ ...config, data_source_name: dataSourceName }, client) + ).rejects.toThrowError( + `Expected exactly 1 result for data_source_name "${dataSourceName}" but got ${expectedResultCount} results` + ); + } + ); + + it.each([ + { + dataSourceName: 'Some Data Source', + }, + { + dataSourceName: 'Some Prefix', + }, + ])( + 'should return valid id when data_source_name exists and is unique', + async ({ dataSourceName }) => { + expect( + await fetchDataSourceIdByName({ ...config, data_source_name: dataSourceName }, client) + ).toBe(validId); + } + ); +}); diff --git a/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.ts b/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.ts new file mode 100644 index 000000000000..e3d0d76d23e7 --- /dev/null +++ b/src/plugins/vis_type_timeline/server/lib/fetch_data_source_id.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'src/core/server'; +import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; +import { getDataSourceEnabled } from './services'; +import { OpenSearchFunctionConfig } from '../types'; + +export const fetchDataSourceIdByName = async ( + config: OpenSearchFunctionConfig, + client: SavedObjectsClientContract +) => { + if (!config.data_source_name) { + return undefined; + } + + if (!getDataSourceEnabled().enabled) { + throw new Error('To query from multiple data sources, first enable the data source feature'); + } + + const dataSources = await client.find({ + type: 'data-source', + perPage: 100, + search: `"${config.data_source_name}"`, + searchFields: ['title'], + fields: ['id', 'title'], + }); + + const possibleDataSourceIds = dataSources.saved_objects.filter( + (obj) => obj.attributes.title === config.data_source_name + ); + + if (possibleDataSourceIds.length !== 1) { + throw new Error( + `Expected exactly 1 result for data_source_name "${config.data_source_name}" but got ${possibleDataSourceIds.length} results` + ); + } + + return possibleDataSourceIds.pop()?.id; +}; diff --git a/src/plugins/vis_type_timeline/server/lib/services.ts b/src/plugins/vis_type_timeline/server/lib/services.ts new file mode 100644 index 000000000000..13b257622abd --- /dev/null +++ b/src/plugins/vis_type_timeline/server/lib/services.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../../opensearch_dashboards_utils/common'; + +export const [getDataSourceEnabled, setDataSourceEnabled] = createGetterSetter<{ + enabled: boolean; +}>('DataSource'); diff --git a/src/plugins/vis_type_timeline/server/plugin.ts b/src/plugins/vis_type_timeline/server/plugin.ts index d2c7097ac419..f768e51e93d0 100644 --- a/src/plugins/vis_type_timeline/server/plugin.ts +++ b/src/plugins/vis_type_timeline/server/plugin.ts @@ -34,6 +34,7 @@ import { TypeOf, schema } from '@osd/config-schema'; import { RecursiveReadonly } from '@osd/utility-types'; import { deepFreeze } from '@osd/std'; +import { DataSourcePluginSetup } from 'src/plugins/data_source/server'; import { PluginStart } from '../../data/server'; import { CoreSetup, PluginInitializerContext } from '../../../core/server'; import { configSchema } from '../config'; @@ -42,11 +43,16 @@ import { functionsRoute } from './routes/functions'; import { validateOpenSearchRoute } from './routes/validate_es'; import { runRoute } from './routes/run'; import { ConfigManager } from './lib/config_manager'; +import { setDataSourceEnabled } from './lib/services'; const experimentalLabel = i18n.translate('timeline.uiSettings.experimentalLabel', { defaultMessage: 'experimental', }); +export interface TimelinePluginSetupDeps { + dataSource?: DataSourcePluginSetup; +} + export interface TimelinePluginStartDeps { data: PluginStart; } @@ -57,7 +63,7 @@ export interface TimelinePluginStartDeps { export class Plugin { constructor(private readonly initializerContext: PluginInitializerContext) {} - public async setup(core: CoreSetup): void { + public async setup(core: CoreSetup, { dataSource }: TimelinePluginSetupDeps): void { const config = await this.initializerContext.config .create>() .pipe(first()) @@ -80,6 +86,8 @@ export class Plugin { ); }; + setDataSourceEnabled({ enabled: !!dataSource }); + const logger = this.initializerContext.logger.get('timeline'); const router = core.http.createRouter(); diff --git a/src/plugins/vis_type_timeline/server/routes/run.ts b/src/plugins/vis_type_timeline/server/routes/run.ts index ab6a993b4bb5..af1005ebcb8f 100644 --- a/src/plugins/vis_type_timeline/server/routes/run.ts +++ b/src/plugins/vis_type_timeline/server/routes/run.ts @@ -122,7 +122,10 @@ export function runRoute( } else { return response.internalError({ body: { - message: err.toString(), + attributes: { + title: err.name, + }, + message: err.message, }, }); } diff --git a/src/plugins/vis_type_timeline/server/series_functions/opensearch/index.js b/src/plugins/vis_type_timeline/server/series_functions/opensearch/index.js index 8837116bfc02..5192059f3e6d 100644 --- a/src/plugins/vis_type_timeline/server/series_functions/opensearch/index.js +++ b/src/plugins/vis_type_timeline/server/series_functions/opensearch/index.js @@ -34,6 +34,7 @@ import { OPENSEARCH_SEARCH_STRATEGY } from '../../../../data/server'; import Datasource from '../../lib/classes/datasource'; import buildRequest from './lib/build_request'; import toSeriesList from './lib/agg_response_to_series_list'; +import { fetchDataSourceIdByName } from '../../lib/fetch_data_source_id'; export default new Datasource('es', { args: [ @@ -112,6 +113,14 @@ export default new Datasource('es', { defaultMessage: `**DO NOT USE THIS**. It's fun for debugging fit functions, but you really should use the interval picker`, }), }, + { + name: 'data_source_name', + types: ['string', 'null'], // If null, the query will proceed with local cluster + help: i18n.translate('timeline.help.functions.opensearch.args.dataSourceNameHelpText', { + defaultMessage: + 'Specify a data source to query from. This will only work if multiple data sources is enabled', + }), + }, ], help: i18n.translate('timeline.help.functions.opensearchHelpText', { defaultMessage: 'Pull data from an opensearch instance', @@ -148,7 +157,15 @@ export default new Datasource('es', { const opensearchShardTimeout = tlConfig.opensearchShardTimeout; - const body = buildRequest(config, tlConfig, scriptedFields, opensearchShardTimeout); + const dataSourceId = await fetchDataSourceIdByName(config, tlConfig.savedObjectsClient); + + const body = buildRequest( + config, + tlConfig, + scriptedFields, + opensearchShardTimeout, + dataSourceId + ); const deps = (await tlConfig.getStartServices())[1]; diff --git a/src/plugins/vis_type_timeline/server/series_functions/opensearch/lib/build_request.js b/src/plugins/vis_type_timeline/server/series_functions/opensearch/lib/build_request.js index 8436b4dbb04a..90fb7b819a08 100644 --- a/src/plugins/vis_type_timeline/server/series_functions/opensearch/lib/build_request.js +++ b/src/plugins/vis_type_timeline/server/series_functions/opensearch/lib/build_request.js @@ -34,7 +34,7 @@ import { buildAggBody } from './agg_body'; import createDateAgg from './create_date_agg'; import { UI_SETTINGS } from '../../../../../data/server'; -export default function buildRequest(config, tlConfig, scriptedFields, timeout) { +export default function buildRequest(config, tlConfig, scriptedFields, timeout, dataSourceId) { const bool = { must: [] }; const timeFilter = { @@ -105,6 +105,7 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) } return { + ...(!!dataSourceId && { dataSourceId }), params: request, }; } diff --git a/src/plugins/vis_type_timeline/server/types.ts b/src/plugins/vis_type_timeline/server/types.ts index f021ffeae00f..2fa3a25c5813 100644 --- a/src/plugins/vis_type_timeline/server/types.ts +++ b/src/plugins/vis_type_timeline/server/types.ts @@ -29,3 +29,18 @@ */ export { TimelineFunctionInterface, TimelineFunctionConfig } from './lib/classes/timeline_function'; + +export interface OpenSearchFunctionConfig { + q: string | null; + metric: string | null; + split: string | null; + index: string | null; + timefield: string | null; + kibana: boolean | null; + opensearchDashboards: boolean | null; + /** + * @deprecated This property should not be set in the Timeline expression. Users should use the interval picker React component instead + */ + interval: string | null; + data_source_name?: string | null; +} From c9613fab5b032885c5bf5ec1f3e731702f492873 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 11:37:22 -0700 Subject: [PATCH 7/9] [2.14][chore] update release notes (#6705) (#6774) Updates releases notes. Partially populated with: yarn release_note:generate And gathered the rest. Cleaned up changelog. related to: #6254 Signed-off-by: Kawika Avilla (cherry picked from commit 8c99ce453112c62d31f1d9d2ad57d7f1417e9265) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md Co-authored-by: github-actions[bot] --- changelogs/fragments/5652.yml | 2 + changelogs/fragments/6427.yml | 2 - changelogs/fragments/6477.yml | 2 - changelogs/fragments/6524.yml | 2 - changelogs/fragments/6527.yml | 2 - changelogs/fragments/6544.yml | 2 - changelogs/fragments/6571.yml | 2 - changelogs/fragments/6575.yml | 2 - changelogs/fragments/6584.yml | 2 - changelogs/fragments/6599.yml | 2 - changelogs/fragments/6625.yml | 2 - changelogs/fragments/6636.yml | 2 - changelogs/fragments/6648.yml | 2 - changelogs/fragments/6668.yml | 2 - changelogs/fragments/6669.yml | 2 - changelogs/fragments/6683.yml | 2 - changelogs/fragments/6712.yml | 2 - changelogs/fragments/6722.yml | 2 - ...nsearch-dashboards.release-notes-2.14.0.md | 116 ++++++++++++++++++ 19 files changed, 118 insertions(+), 34 deletions(-) create mode 100644 changelogs/fragments/5652.yml delete mode 100644 changelogs/fragments/6427.yml delete mode 100644 changelogs/fragments/6477.yml delete mode 100644 changelogs/fragments/6524.yml delete mode 100644 changelogs/fragments/6527.yml delete mode 100644 changelogs/fragments/6544.yml delete mode 100644 changelogs/fragments/6571.yml delete mode 100644 changelogs/fragments/6575.yml delete mode 100644 changelogs/fragments/6584.yml delete mode 100644 changelogs/fragments/6599.yml delete mode 100644 changelogs/fragments/6625.yml delete mode 100644 changelogs/fragments/6636.yml delete mode 100644 changelogs/fragments/6648.yml delete mode 100644 changelogs/fragments/6668.yml delete mode 100644 changelogs/fragments/6669.yml delete mode 100644 changelogs/fragments/6683.yml delete mode 100644 changelogs/fragments/6712.yml delete mode 100644 changelogs/fragments/6722.yml create mode 100644 release-notes/opensearch-dashboards.release-notes-2.14.0.md diff --git a/changelogs/fragments/5652.yml b/changelogs/fragments/5652.yml new file mode 100644 index 000000000000..fe6ed9dc1da3 --- /dev/null +++ b/changelogs/fragments/5652.yml @@ -0,0 +1,2 @@ +feat: +- Make theme and dark mode settings user/device specific (in local storage), with opt-out ([#5652](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5652)) \ No newline at end of file diff --git a/changelogs/fragments/6427.yml b/changelogs/fragments/6427.yml deleted file mode 100644 index 74066816aad3..000000000000 --- a/changelogs/fragments/6427.yml +++ /dev/null @@ -1,2 +0,0 @@ -feat: -- [Workspace] Allow making apps available in workspaces using `workspaceAvailability` ([#6427](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6427)) \ No newline at end of file diff --git a/changelogs/fragments/6477.yml b/changelogs/fragments/6477.yml deleted file mode 100644 index fe9c055906f4..000000000000 --- a/changelogs/fragments/6477.yml +++ /dev/null @@ -1,2 +0,0 @@ -refactor: -- Refactor dev tool to use dataSourceManagement.ui API to get DataSourceSelector ([#6477](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6477)) \ No newline at end of file diff --git a/changelogs/fragments/6524.yml b/changelogs/fragments/6524.yml deleted file mode 100644 index 1c7c99bb0145..000000000000 --- a/changelogs/fragments/6524.yml +++ /dev/null @@ -1,2 +0,0 @@ -feat: -- [Workspace] Handle data sources and advanced settings as global object. ([#6524](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6524)) \ No newline at end of file diff --git a/changelogs/fragments/6527.yml b/changelogs/fragments/6527.yml deleted file mode 100644 index 0ae1aef9cac1..000000000000 --- a/changelogs/fragments/6527.yml +++ /dev/null @@ -1,2 +0,0 @@ -fix: -- Permission check failed with empty workspace for find method ([#6527](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6527)) \ No newline at end of file diff --git a/changelogs/fragments/6544.yml b/changelogs/fragments/6544.yml deleted file mode 100644 index 53d6283a6d47..000000000000 --- a/changelogs/fragments/6544.yml +++ /dev/null @@ -1,2 +0,0 @@ -refactor: -- Refactor saved object management plugin to use datasourceManagement ui API to get DataSourceSelector ([#6544](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6544)) \ No newline at end of file diff --git a/changelogs/fragments/6571.yml b/changelogs/fragments/6571.yml deleted file mode 100644 index a6e341fc15e4..000000000000 --- a/changelogs/fragments/6571.yml +++ /dev/null @@ -1,2 +0,0 @@ -refactor: -- discover data selector enhancement and refactoring ([#6571](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6571)) \ No newline at end of file diff --git a/changelogs/fragments/6575.yml b/changelogs/fragments/6575.yml deleted file mode 100644 index 964c04770a83..000000000000 --- a/changelogs/fragments/6575.yml +++ /dev/null @@ -1,2 +0,0 @@ -feat: -- [Workspace] Make dashboards management available ([#6575](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6575)) \ No newline at end of file diff --git a/changelogs/fragments/6584.yml b/changelogs/fragments/6584.yml deleted file mode 100644 index 69b9d2471271..000000000000 --- a/changelogs/fragments/6584.yml +++ /dev/null @@ -1,2 +0,0 @@ -feat: -- [Workspace] Add workspace overview page ([#6584](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6584)) \ No newline at end of file diff --git a/changelogs/fragments/6599.yml b/changelogs/fragments/6599.yml deleted file mode 100644 index 0cdaf731e500..000000000000 --- a/changelogs/fragments/6599.yml +++ /dev/null @@ -1,2 +0,0 @@ -feat: -- Improve the perceived performance of Discover when using the default tabular renderer ([#6599](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6599)) diff --git a/changelogs/fragments/6625.yml b/changelogs/fragments/6625.yml deleted file mode 100644 index cd5e6e8ead0a..000000000000 --- a/changelogs/fragments/6625.yml +++ /dev/null @@ -1,2 +0,0 @@ -feat: -- [Workspace] Hide dashboard overview ([#6625](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6625)) \ No newline at end of file diff --git a/changelogs/fragments/6636.yml b/changelogs/fragments/6636.yml deleted file mode 100644 index 2434dd458b9f..000000000000 --- a/changelogs/fragments/6636.yml +++ /dev/null @@ -1,2 +0,0 @@ -fix: -- [BUG] Allow Save in Top Nav Menu to capture filter and query ([#6636](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6636)) \ No newline at end of file diff --git a/changelogs/fragments/6648.yml b/changelogs/fragments/6648.yml deleted file mode 100644 index e3d207dedbcd..000000000000 --- a/changelogs/fragments/6648.yml +++ /dev/null @@ -1,2 +0,0 @@ -fix: -- Fix datasource test connect error ([#6648](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6648)) \ No newline at end of file diff --git a/changelogs/fragments/6668.yml b/changelogs/fragments/6668.yml deleted file mode 100644 index 239ee0257197..000000000000 --- a/changelogs/fragments/6668.yml +++ /dev/null @@ -1,2 +0,0 @@ -fix: -- Keep disallowed types when importing with overwrite ([#6668](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6668)) \ No newline at end of file diff --git a/changelogs/fragments/6669.yml b/changelogs/fragments/6669.yml deleted file mode 100644 index 1e68c7b5477a..000000000000 --- a/changelogs/fragments/6669.yml +++ /dev/null @@ -1,2 +0,0 @@ -fix: -- Optimization on handling invalid workspace id in workspace_ui_settings wrapper ([#6669](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6669)) \ No newline at end of file diff --git a/changelogs/fragments/6683.yml b/changelogs/fragments/6683.yml deleted file mode 100644 index 1a065c9c0c51..000000000000 --- a/changelogs/fragments/6683.yml +++ /dev/null @@ -1,2 +0,0 @@ -feat: -- Optimize scrolling behavior of Discover table ([#6683](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6683)) diff --git a/changelogs/fragments/6712.yml b/changelogs/fragments/6712.yml deleted file mode 100644 index 36e5ebc65001..000000000000 --- a/changelogs/fragments/6712.yml +++ /dev/null @@ -1,2 +0,0 @@ -feat: -- Add `opensearchDashboards.futureNavigation` config to control dev tool top right nav button. ([#6712](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6712)) \ No newline at end of file diff --git a/changelogs/fragments/6722.yml b/changelogs/fragments/6722.yml deleted file mode 100644 index 6e4c4511ac0a..000000000000 --- a/changelogs/fragments/6722.yml +++ /dev/null @@ -1,2 +0,0 @@ -fix: -- Test failures related to #6443 ([#6722](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6722)) \ No newline at end of file diff --git a/release-notes/opensearch-dashboards.release-notes-2.14.0.md b/release-notes/opensearch-dashboards.release-notes-2.14.0.md new file mode 100644 index 000000000000..37dcf89f3eb9 --- /dev/null +++ b/release-notes/opensearch-dashboards.release-notes-2.14.0.md @@ -0,0 +1,116 @@ +## Version 2.14.0 Release Notes + +### 📈 Features/Enhancements + + - Add `opensearchDashboards.futureNavigation` config to control dev tool top right nav button. ([#6712](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6712)) + - Adds `migrations.delete` to delete saved objects by type during a migration ([#6443](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6443)) + - Parse query string filters to determine if fields match an index when `ignoreFilterIfFieldNotInIndex` is enabled ([#6126](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6126)) + - [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075)) + - [Workspace] Add ACL related functions ([#5084](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5084/)) + - [Workspace] Optional workspaces params in repository ([#5949](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5949)) + - [Workspace] Add delete saved objects by workspace functionality([#6013](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6013)) + - [Workspace] Consume workspace id in saved object client ([#6014](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6014)) + - [Workspace] Add permission control logic ([#6052](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6052)) + - [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) + - [Chrome] Introduce registerCollapsibleNavHeader to allow plugins to customize the rendering of nav menu header ([#5244](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5244)) + - [Workspace] Allow making apps available in workspaces using `workspaceAvailability` ([#6427](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6427)) + - [Workspace] Handle data sources and advanced settings as global object. ([#6524](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6524)) + - [Workspace] Make dashboards management available ([#6575](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6575)) + - [Workspace] Add workspace overview page ([#6584](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6584)) + - Improve the perceived performance of Discover when using the default tabular renderer ([#6599](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6599)) + - [Workspace] Hide dashboard overview ([#6625](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6625)) + - Optimize scrolling behavior of Discover table ([#6683](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6683)) + - [Discover] Add extension group title to non-index data source groups to indicate log explorer redirection in discover data source selector. ([#5815](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5815)) + - [Multiple Datasource] Create data source menu component able to be mount to nav bar ([#6082](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6082)) + - [Multiple Datasource] Expose filterfn in datasource menu component to allow filter data sources before rendering in navigation bar ([#6113](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6113)) + - [Multiple Datasource] Add component to show single selected data source in read only mode ([#6113](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6125)) + - [Multiple Datasource] Add data source aggregated view to show all compatible data sources or only show used data sources ([#6129](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6129)) + - [Workspace] Register a workspace dropdown menu at the top of left nav bar ([#6150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6150)) + - [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154)) + - [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171)) + - [Multiple Datasource] Use data source filter function before rendering ([#6175](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6175)) + - [Workspace] Add create workspace page ([#6179](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6179)) + - [Workspace] Add workspace list page ([#6182](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6182)) + - Enable UI Metric Collector to collect UI Metrics and Application Usage ([#6203](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6203)) + - [Multiple Datasource] Add multi selectable data source component ([#6211](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6211)) + - [Multiple Datasource] Add multi data source support to sample vega visualizations ([#6218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6218)) + - [Workspace] Add workspaces column to saved objects page ([#6225](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6225)) + - [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231)) + - [Workspace] Filter left nav menu items according to the current workspace ([#6234](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6234)) + - [Multiple Datasource] Make sure customer always have a default datasource ([#6237](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6237)) + - [Multiple DataSource] Codebase maintenance involves updating typos and removing unused imported packages ([#6238](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6238)) + - [Multiple Datasource] Refactor data source menu and interface to allow cleaner selection of component and related configurations ([#6256](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6256)) + - [Multiple Datasource] Remove arrow down icon from data source selectable component ([#6257](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6257)) + - [Multiple Datasource] Allow top nav menu to mount data source menu for use case when both menus are mounted ([#6268](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6268)) + - [Workspace] Add update workspace page ([#6270](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6270)) + - [Workspace] Add API to duplicate saved objects among workspaces ([#6288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6288)) + - [Multiple Datasource] Enhanced data source selector with default datasource shows as first choice ([#6293](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6293)) + - [Mulitple Datasource] Add multi data source support to TSVB ([#6298](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6298)) + - [Workspace] Add APIs to support plugin state in request ([#6303](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6303)) + - [Multiple Datasource] Fetch data source title for DataSourceView when only id is provided ([#6315](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6315)) + - [Multiple Datasource] Add default icon for selectable component and make sure the default datasource shows automatically ([#6327](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6327)) + - [Multiple Datasource] Pass selected data sources to plugin consumers when the multi-select component initially loads ([#6333](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6333)) + - Allow the use of `ignoreVersionMismatch` in non-dev configuration ([#6347](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6347)) + - [Multiple Datasource] Add installedPlugins list to data source saved object ([#6348](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6348)) + - [Multiple Datasource] Add default icon in multi-selectable picker ([#6357](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6357)) + - [Multiple Datasource] Get data source label when only id is provided in DataSourceSelectable ([#6358](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6358)) + simplifying client fetch ([#6364](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6364)) + - [Dynamic Configurations] Improve dynamic configurations by adding cache and simplifying client fetch ([#6364](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6364)) + - [Workspace] Support workspace in saved objects client in server side. ([#6365](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6365)) + - [Multiple Datasource] Refactor data source selector component to include placeholder and add tests ([#6372](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6372)) + - [Workspace] Add permission tab to workspace create update page ([#6378](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6378)) + - [CSP Handler] Update CSP handler to only query and modify frame ancestors instead of all CSP directives ([#6398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6398)) + - Replace control characters before logging ([#6402](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6402)) + - [MD] Add dropdown header to data source single selector ([#6431](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6431)) + - [Multiple Datasource] Add error state to all data source menu components to show error component and consolidate all fetch errors ([#6440](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6440)) + - [Workspace] Hide datasource and advanced settings menu in dashboard management when in workspace. ([#6455](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6455)) + - [Workspace] Add workspaces filter to saved objects page. ([#6458](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6458)) + - [Multiple Datasource] UI change for datasource view picker to enable selectable([#6497](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6497)) + - [Multiple Datasource] Add popover for empty state and redirect to data source management page([#6514](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6514)) + - [Multiple Datasource] Modify selectable picker to remove group label and close popover after selection ([#6515](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6515)) + - [Multiple Datasource] Update empty state font size and footer button size to small ([6549](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6549)) + - Add `rightNavigationButton` component in chrome service for applications to register and add dev tool to top right navigation. ([#6553](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6553)) + - [Multiple Datasource] Extract the button component for datasource picker to avoid duplicate code ([#6559](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6559)) + - [Workspace] Add a workspace client in workspace plugin ([#6094](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6094)) + - [Multiple Datasource] Support multi data source in Region map ([#6654](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6654)) + - [Multiple Datasource] Add empty state component for no connected data source ([#6499](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6499)) + - [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400)) + +### 🐛 Bug Fixes + + - [Dev Tool] Add additional themed styles to ace overrides ([#5327](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5327)) + - [Workspace] Permission check failed with empty workspace for find method ([#6527](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6527)) + - Allow Save in Top Nav Menu to capture filter and query ([#6636](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6636)) + - Fix datasource test connect error ([#6648](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6648)) + - [Workspace] Keep disallowed types when importing with overwrite ([#6668](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6668)) + - [Workspace] Optimization on handling invalid workspace id in workspace_ui_settings wrapper ([#6669](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6669)) + - [Discover] Fix lazy loading of the legacy table from getting stuck ([#6041](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6041)) + - [BUG][Multiple Datasource] Fix obsolete snapshots for test within data source management plugin ([#6185](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6185)) + - [Workspace] Add base path when parse url in http service ([#6233](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6233)) + - [BUG] Fix for checkForFunctionProperty so that order does not matter ([#6248](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6248)) + - [Multiple Datasource] Fix sslConfig for multiple datasource to handle when certificateAuthorities is unset ([#6282](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6282)) + - [BUG][Multiple Datasource]Fix bug in data source aggregated view to change it to depend on displayAllCompatibleDataSources property to show the badge value ([#6291](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6291)) + - [BUG][Multiple Datasource]Read hideLocalCluster setting from yml and set in data source selector and data source menu ([#6361](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6361)) + - [BUG][Multiple Datasource] Refactor read-only component to cover more edge cases ([#6416](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6416)) + - [BUG][Multiple Datasource] Fix style of data source option inside popover for data source selector, selectable, multi select components ([#6438](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6438)) + - [BUG][Multiple Datasource] Add validation for title length to be no longer than 32 characters [#6452](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6452) + - [VisBuilder] Allow saving and loading filter and query in a saved VisBuilder ([#6460](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6460)) + - [BUG][Multiple Datasource] Modify the button of selectable component to fix the title overflow issue ([#6465](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6465)) + - [Dynamic Configurations] Fix dynamic config API calls to pass correct input ([#6474](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6474)) + - [BUG][Multiple Datasource] Fix on data source selectable and readonly component are not consistent ([#6545]https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6545) + +### 🚞 Infrastructure + + - Update link-checker and clean up ignore-list ([#6425](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6425)) + +### 🪛 Refactoring + + - Refactor dev tool to use dataSourceManagement.ui API to get DataSourceSelector ([#6477](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6477)) + - Refactor saved object management plugin to use datasourceManagement ui API to get DataSourceSelector ([#6544](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6544)) + - discover data selector enhancement and refactoring ([#6571](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6571)) + - [Multiple Datasource] Move data source selectable to its own folder, fix test and a few type errors for data source selectable component ([#6287](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6287)) + - [Multiple Datasource] Remove duplicate data source attribute interface from `data_source_management` ([#6437](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6437)) + +### 🔩 Tests + + - Add functional test cypress workflow improvements and enable the workflow for in-house Dashboards tests ([#6061](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6061)) From d4c4b414dedb8faa589c984a0e0b492a0f24adc0 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 11:15:24 -0700 Subject: [PATCH 8/9] add http://www.site.com to lycheeignore (#6771) (#6781) * add http://www.site.com to lycheeignore * Changeset file for PR #6771 created/updated * Update .lycheeignore --------- (cherry picked from commit d5857551d5c6cee47f5bc24d1206704ca0461a89) Signed-off-by: yujin-emma Signed-off-by: Yu Jin <112784385+yujin-emma@users.noreply.github.com> Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: Miki --- .lycheeignore | 1 + changelogs/fragments/6771.yml | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 changelogs/fragments/6771.yml diff --git a/.lycheeignore b/.lycheeignore index 6d000f2d65ae..89b3c520d87d 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -88,3 +88,4 @@ https://unpkg.com/@elastic/ https://codeload.github.com/ https://www.quandl.com/api/v1/datasets/ https://code.google.com/p/v8/wiki/JavaScriptStackTraceApi +site.com diff --git a/changelogs/fragments/6771.yml b/changelogs/fragments/6771.yml new file mode 100644 index 000000000000..454dd0d17565 --- /dev/null +++ b/changelogs/fragments/6771.yml @@ -0,0 +1,2 @@ +fix: +- Lint checker failure fix ([#6771](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6771)) \ No newline at end of file From 044762806e1987530807711acf443a8177f3fc64 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 11:16:10 -0700 Subject: [PATCH 9/9] Make Field Name Search Filter Case Insensitive (#6759) (#6761) * Make Field Name Filter Case Insensitive * Changeset file for PR #6759 created/updated --------- (cherry picked from commit a3d255e16d0e8d852bbea25c4f904e9c437e8386) Signed-off-by: Suchit Sahoo Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/6759.yml | 2 ++ .../public/application/components/sidebar/lib/field_filter.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/6759.yml diff --git a/changelogs/fragments/6759.yml b/changelogs/fragments/6759.yml new file mode 100644 index 000000000000..ef7e5cddddda --- /dev/null +++ b/changelogs/fragments/6759.yml @@ -0,0 +1,2 @@ +feat: +- Make Field Name Search Filter Case Insensitive ([#6759](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6759)) \ No newline at end of file diff --git a/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts index d72af29b43e0..599e546058d2 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts @@ -83,7 +83,8 @@ export function isFieldFiltered( field.type === '_source' || field.scripted || fieldCounts[field.name] > 0; - const matchName = !filterState.name || field.name.indexOf(filterState.name) !== -1; + const matchName = + !filterState.name || field.name.toLowerCase().indexOf(filterState.name.toLowerCase()) !== -1; // case insensitive matching name return matchFilter && isAggregatable && isSearchable && scriptedOrMissing && matchName; }