diff --git a/changelogs/fragments/7716.yml b/changelogs/fragments/7716.yml new file mode 100644 index 000000000000..d1b91ff51d89 --- /dev/null +++ b/changelogs/fragments/7716.yml @@ -0,0 +1,2 @@ +feat: +- Display workspace picker content when outside workspace ([#7716](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7716)) \ No newline at end of file diff --git a/changelogs/fragments/7866.yml b/changelogs/fragments/7866.yml new file mode 100644 index 000000000000..9cc5d4863d55 --- /dev/null +++ b/changelogs/fragments/7866.yml @@ -0,0 +1,2 @@ +feat: +- Query editor UI changes ([#7866](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7866)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 7ba83c9248b6..47de509b4483 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -359,7 +359,13 @@ # 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 +# Set the value to true to enable enhancements for the data plugin +# data.enhancements.enabled: false + +# Set the value to true to enable dynamic config service to obtain configs from a config store. By default, it's disabled +# dynamic_config_service.enabled: false + # Set the backend roles in groups or users, whoever has the backend roles or exactly match the user ids defined in this config will be regard as dashboard admin. # Dashboard admin will have the access to all the workspaces(workspace.enabled: true) and objects inside OpenSearch Dashboards. # opensearchDashboards.dashboardAdmin.groups: ["dashboard_admin"] -# opensearchDashboards.dashboardAdmin.users: ["dashboard_admin"] \ No newline at end of file +# opensearchDashboards.dashboardAdmin.users: ["dashboard_admin"] diff --git a/src/core/public/chrome/nav_group/nav_group_service.test.ts b/src/core/public/chrome/nav_group/nav_group_service.test.ts index 91a6b2a0a6de..06712058fb23 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.test.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.test.ts @@ -14,7 +14,7 @@ import { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.moc import { NavLinksService } from '../nav_links'; import { applicationServiceMock, httpServiceMock, workspacesServiceMock } from '../../mocks'; import { AppCategory } from 'opensearch-dashboards/public'; -import { DEFAULT_NAV_GROUPS } from '../../'; +import { DEFAULT_NAV_GROUPS, NavGroupStatus, ALL_USE_CASE_ID } from '../../'; import { ChromeBreadcrumbEnricher } from '../chrome_service'; const mockedGroupFoo = { @@ -381,7 +381,50 @@ describe('ChromeNavGroupService#start()', () => { expect(currentNavGroup?.title).toEqual('barGroupTitle'); }); - it('should erase current nav group if application is home', async () => { + it('should be able to find the right nav group when visible nav group is all', async () => { + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const navGroupEnabled$ = new Rx.BehaviorSubject(true); + uiSettings.get$.mockImplementation(() => navGroupEnabled$); + + const chromeNavGroupService = new ChromeNavGroupService(); + const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); + + chromeNavGroupServiceSetup.addNavLinksToGroup( + { + id: ALL_USE_CASE_ID, + title: 'fooGroupTitle', + description: 'foo description', + }, + [mockedNavLinkFoo] + ); + + chromeNavGroupServiceSetup.addNavLinksToGroup( + { + id: 'bar-group', + title: 'barGroupTitle', + description: 'bar description', + status: NavGroupStatus.Hidden, + }, + [mockedNavLinkFoo, mockedNavLinkBar] + ); + + const chromeNavGroupServiceStart = await chromeNavGroupService.start({ + navLinks: mockedNavLinkService, + application: mockedApplicationService, + breadcrumbsEnricher$: new Rx.BehaviorSubject(undefined), + workspaces: workspacesServiceMock.createStartContract(), + }); + mockedApplicationService.navigateToApp(mockedNavLinkBar.id); + + const currentNavGroup = await chromeNavGroupServiceStart + .getCurrentNavGroup$() + .pipe(first()) + .toPromise(); + + expect(currentNavGroup?.id).toEqual('bar-group'); + }); + + it('should erase current nav group if application can not be found in any of the visible nav groups', async () => { const uiSettings = uiSettingsServiceMock.createSetupContract(); const navGroupEnabled$ = new Rx.BehaviorSubject(true); uiSettings.get$.mockImplementation(() => navGroupEnabled$); @@ -403,6 +446,7 @@ describe('ChromeNavGroupService#start()', () => { id: 'bar-group', title: 'barGroupTitle', description: 'bar description', + status: NavGroupStatus.Hidden, }, [mockedNavLinkFoo, mockedNavLinkBar] ); @@ -416,7 +460,7 @@ describe('ChromeNavGroupService#start()', () => { chromeNavGroupServiceStart.setCurrentNavGroup('foo-group'); - mockedApplicationService.navigateToApp('home'); + mockedApplicationService.navigateToApp(mockedNavLinkBar.id); const currentNavGroup = await chromeNavGroupServiceStart .getCurrentNavGroup$() .pipe(first()) diff --git a/src/core/public/chrome/nav_group/nav_group_service.ts b/src/core/public/chrome/nav_group/nav_group_service.ts index 88689d88f2fc..729239081b42 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.ts @@ -14,11 +14,16 @@ import { import { map, switchMap, takeUntil } from 'rxjs/operators'; import { i18n } from '@osd/i18n'; import { IUiSettingsClient } from '../../ui_settings'; -import { fulfillRegistrationLinksToChromeNavLinks, getSortedNavLinks } from '../utils'; +import { + fulfillRegistrationLinksToChromeNavLinks, + getSortedNavLinks, + getVisibleUseCases, +} from '../utils'; import { ChromeNavLinks } from '../nav_links'; import { InternalApplicationStart } from '../../application'; import { NavGroupStatus } from '../../../../core/types'; import { ChromeBreadcrumb, ChromeBreadcrumbEnricher } from '../chrome_service'; +import { ALL_USE_CASE_ID } from '../../../utils'; export const CURRENT_NAV_GROUP_ID = 'core.chrome.currentNavGroupId'; @@ -211,7 +216,7 @@ export class ChromeNavGroupService { const setCurrentNavGroup = (navGroupId: string | undefined) => { const navGroup = navGroupId ? this.navGroupsMap$.getValue()[navGroupId] : undefined; - if (navGroup && navGroup.status !== NavGroupStatus.Hidden) { + if (navGroup) { this.currentNavGroup$.next(navGroup); sessionStorage.setItem(CURRENT_NAV_GROUP_ID, navGroup.id); } else { @@ -254,28 +259,37 @@ export class ChromeNavGroupService { application.currentAppId$, this.getSortedNavGroupsMap$(), ]).subscribe(([appId, navGroupMap]) => { - if (appId === 'home') { - setCurrentNavGroup(undefined); - return; - } if (appId && navGroupMap) { const appIdNavGroupMap = new Map>(); - // iterate navGroupMap - Object.keys(navGroupMap) + const visibleUseCases = getVisibleUseCases(navGroupMap); + const mapAppIdToNavGroup = (navGroup: NavGroupItemInMap) => { + navGroup.navLinks.forEach((navLink) => { + const navLinkId = navLink.id; + const navGroupSet = appIdNavGroupMap.get(navLinkId) || new Set(); + navGroupSet.add(navGroup.id); + appIdNavGroupMap.set(navLinkId, navGroupSet); + }); + }; + if (visibleUseCases.length === 1 && visibleUseCases[0].id === ALL_USE_CASE_ID) { + // If the only visible use case is all use case + // All the other nav groups will be visible because all use case can visit all of the nav groups. + Object.values(navGroupMap).forEach((navGroup) => mapAppIdToNavGroup(navGroup)); + } else { // Nav group of Hidden status should be filtered out when counting navGroups the currentApp belongs to - .filter((navGroupId) => navGroupMap[navGroupId].status !== NavGroupStatus.Hidden) - .forEach((navGroupId) => { - navGroupMap[navGroupId].navLinks.forEach((navLink) => { - const navLinkId = navLink.id; - const navGroupSet = appIdNavGroupMap.get(navLinkId) || new Set(); - navGroupSet.add(navGroupId); - appIdNavGroupMap.set(navLinkId, navGroupSet); - }); + Object.values(navGroupMap).forEach((navGroup) => { + if (navGroup.status === NavGroupStatus.Hidden) { + return; + } + + mapAppIdToNavGroup(navGroup); }); + } const navGroups = appIdNavGroupMap.get(appId); if (navGroups && navGroups.size === 1) { setCurrentNavGroup(navGroups.values().next().value); + } else if (!navGroups) { + setCurrentNavGroup(undefined); } } }); diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap index 7f896674faac..55554ffabf70 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap @@ -412,206 +412,3 @@ exports[` should show all use case when current na `; - -exports[` should render correctly 1`] = ` -
-
- -
-
-`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_groups.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_groups.test.tsx.snap new file mode 100644 index 000000000000..3d923a49dd80 --- /dev/null +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_groups.test.tsx.snap @@ -0,0 +1,204 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render correctly 1`] = ` +
+
+ +
+
+`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 168407a7de4f..88f06ee9edcb 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -160,43 +160,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -6358,43 +6321,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -7346,43 +7272,6 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -9331,43 +9220,6 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -9853,43 +9705,6 @@ exports[`Header renders condensed header 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -14822,43 +14637,6 @@ exports[`Header renders condensed header 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -15307,43 +15085,6 @@ exports[`Header renders page header with application title 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -18669,43 +18410,6 @@ exports[`Header renders page header with application title 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -19191,43 +18895,6 @@ exports[`Header toggles primary navigation menu when clicked 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, @@ -24160,43 +23827,6 @@ exports[`Header toggles primary navigation menu when clicked 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, }, diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx index 98b2ade3e257..418dca694e21 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx @@ -10,13 +10,17 @@ import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; import { CollapsibleNavGroupEnabled, CollapsibleNavGroupEnabledProps, - NavGroups, } from './collapsible_nav_group_enabled'; import { ChromeNavLink } from '../../nav_links'; -import { ChromeRegistrationNavLink, NavGroupItemInMap } from '../../nav_group'; +import { NavGroupItemInMap } from '../../nav_group'; import { httpServiceMock } from '../../../mocks'; import { getLogos } from '../../../../common'; -import { ALL_USE_CASE_ID, DEFAULT_NAV_GROUPS, WorkspaceObject } from '../../../../public'; +import { + ALL_USE_CASE_ID, + DEFAULT_APP_CATEGORIES, + DEFAULT_NAV_GROUPS, + WorkspaceObject, +} from '../../../../public'; import { capabilitiesServiceMock } from '../../../application/capabilities/capabilities_service.mock'; jest.mock('./collapsible_nav_group_enabled_top', () => ({ @@ -25,75 +29,6 @@ jest.mock('./collapsible_nav_group_enabled_top', () => ({ const mockBasePath = httpServiceMock.createSetupContract({ basePath: '/test' }).basePath; -describe('', () => { - const getMockedNavLink = ( - navLink: Partial - ): ChromeNavLink & ChromeRegistrationNavLink => ({ - baseUrl: '', - href: '', - id: '', - title: '', - ...navLink, - }); - it('should render correctly', () => { - const navigateToApp = jest.fn(); - const onNavItemClick = jest.fn(); - const { container, getByTestId, queryByTestId } = render( - - ); - expect(container).toMatchSnapshot(); - expect(container.querySelectorAll('.nav-link-item-btn').length).toEqual(5); - fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); - expect(navigateToApp).toBeCalledTimes(0); - // The accordion is collapsed - expect(queryByTestId('collapsibleNavAppLink-subLink')).toBeNull(); - - // Expand the accordion - fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); - fireEvent.click(getByTestId('collapsibleNavAppLink-subLink')); - expect(navigateToApp).toBeCalledWith('subLink'); - }); -}); - const defaultNavGroupMap = { [ALL_USE_CASE_ID]: { ...DEFAULT_NAV_GROUPS[ALL_USE_CASE_ID], @@ -342,4 +277,33 @@ describe('', () => { expect(getByTestId('collapsibleNavAppLink-link-in-essentials')).toBeInTheDocument(); expect(queryAllByTestId('collapsibleNavAppLink-link-in-all').length).toEqual(1); }); + + it('should render manage category when in all use case if workspace disabled', () => { + const props = mockProps({ + currentNavGroupId: ALL_USE_CASE_ID, + navGroupsMap: { + ...defaultNavGroupMap, + [DEFAULT_NAV_GROUPS.dataAdministration.id]: { + ...DEFAULT_NAV_GROUPS.dataAdministration, + navLinks: [ + { + id: 'link-in-dataAdministration', + title: 'link-in-dataAdministration', + }, + ], + }, + }, + navLinks: [ + { + id: 'link-in-dataAdministration', + title: 'link-in-dataAdministration', + baseUrl: '', + href: '', + }, + ], + }); + const { getByText } = render(); + // Should render manage category + expect(getByText(DEFAULT_APP_CATEGORIES.manage.label)).toBeInTheDocument(); + }); }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx index d196e760e43b..d8867d973d7d 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -4,15 +4,7 @@ */ import './collapsible_nav_group_enabled.scss'; -import { - EuiFlexItem, - EuiFlyout, - EuiSideNavItemType, - EuiSideNav, - EuiPanel, - EuiText, - EuiHorizontalRule, -} from '@elastic/eui'; +import { EuiFlyout, EuiPanel, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React, { useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; @@ -20,7 +12,7 @@ import * as Rx from 'rxjs'; import classNames from 'classnames'; import { WorkspacesStart } from 'src/core/public/workspace'; import { ChromeNavControl, ChromeNavLink } from '../..'; -import { AppCategory, NavGroupStatus } from '../../../../types'; +import { AppCategory, NavGroupType } from '../../../../types'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; @@ -31,18 +23,15 @@ import { ChromeRegistrationNavLink, NavGroupItemInMap, } from '../../nav_group'; -import { - fulfillRegistrationLinksToChromeNavLinks, - getOrderedLinksOrCategories, - LinkItem, - LinkItemType, -} from '../../utils'; +import { fulfillRegistrationLinksToChromeNavLinks, getVisibleUseCases, sortBy } from '../../utils'; import { ALL_USE_CASE_ID, DEFAULT_APP_CATEGORIES } from '../../../../../core/utils'; import { CollapsibleNavTop } from './collapsible_nav_group_enabled_top'; import { HeaderNavControls } from './header_nav_controls'; +import { NavGroups } from './collapsible_nav_groups'; export interface CollapsibleNavGroupEnabledProps { appId$: InternalApplicationStart['currentAppId$']; + collapsibleNavHeaderRender?: () => JSX.Element | null; basePath: HttpStart['basePath']; id: string; isLocked: boolean; @@ -63,147 +52,16 @@ export interface CollapsibleNavGroupEnabledProps { currentWorkspace$: WorkspacesStart['currentWorkspace$']; } -interface NavGroupsProps { - navLinks: ChromeNavLink[]; - suffix?: React.ReactElement; - style?: React.CSSProperties; - appId?: string; - navigateToApp: InternalApplicationStart['navigateToApp']; - onNavItemClick: ( - event: React.MouseEvent, - navItem: ChromeNavLink - ) => void; -} - const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { defaultMessage: 'See all...', }); -const LEVEL_FOR_ROOT_ITEMS = 1; - -export function NavGroups({ - navLinks, - suffix, - style, - appId, - navigateToApp, - onNavItemClick, -}: NavGroupsProps) { - const createNavItem = ({ - link, - className, - }: { - link: ChromeNavLink; - className?: string; - }): EuiSideNavItemType<{}> => { - const euiListItem = createEuiListItem({ - link, - appId, - dataTestSubj: `collapsibleNavAppLink-${link.id}`, - navigateToApp, - onClick: (event) => { - onNavItemClick(event, link); - }, - }); - - return { - id: `${link.id}-${link.title}`, - name: {link.title}, - onClick: euiListItem.onClick, - href: euiListItem.href, - emphasize: euiListItem.isActive, - className: `nav-link-item ${className || ''}`, - buttonClassName: 'nav-link-item-btn', - 'data-test-subj': euiListItem['data-test-subj'], - 'aria-label': link.title, - }; - }; - const createSideNavItem = ( - navLink: LinkItem, - level: number, - className?: string - ): EuiSideNavItemType<{}> => { - if (navLink.itemType === LinkItemType.LINK) { - if (navLink.link.title === titleForSeeAll) { - const navItem = createNavItem({ - link: navLink.link, - }); - - return { - ...navItem, - name: {navItem.name}, - emphasize: false, - }; - } - - return createNavItem({ - link: navLink.link, - className, - }); - } - - if (navLink.itemType === LinkItemType.PARENT_LINK && navLink.link) { - const props = createNavItem({ link: navLink.link }); - const parentItem = { - ...props, - forceOpen: true, - /** - * The href and onClick should both be undefined to make parent item rendered as accordion. - */ - href: undefined, - onClick: undefined, - className: classNames(props.className, 'nav-link-parent-item'), - buttonClassName: classNames(props.buttonClassName, 'nav-link-parent-item-button'), - items: navLink.links.map((subNavLink) => - createSideNavItem(subNavLink, level + 1, 'nav-nested-item') - ), - }; - /** - * OuiSideBar will never render items of first level as accordion, - * in order to display accordion, we need to render a fake parent item. - */ - if (level === LEVEL_FOR_ROOT_ITEMS) { - return { - className: 'nav-link-fake-item', - buttonClassName: 'nav-link-fake-item-button', - name: '', - items: [parentItem], - id: `fake_${props.id}`, - }; - } - - return parentItem; - } - - if (navLink.itemType === LinkItemType.CATEGORY) { - return { - id: navLink.category?.id ?? '', - name:
{navLink.category?.label ?? ''}
, - items: navLink.links?.map((link) => createSideNavItem(link, level + 1)), - 'aria-label': navLink.category?.label, - }; - } - - return {} as EuiSideNavItemType<{}>; - }; - const orderedLinksOrCategories = getOrderedLinksOrCategories(navLinks); - const sideNavItems = orderedLinksOrCategories - .map((navLink) => createSideNavItem(navLink, LEVEL_FOR_ROOT_ITEMS)) - .filter((item): item is EuiSideNavItemType<{}> => !!item); - return ( - - - {suffix} - - ); -} - // Custom category is used for those features not belong to any of use cases in all use case. -// and the custom category should always sit before manage category +// and the custom category should always sit after manage category const customCategory: AppCategory = { id: 'custom', label: i18n.translate('core.ui.customNavList.label', { defaultMessage: 'Custom' }), - order: (DEFAULT_APP_CATEGORIES.manage.order || 0) - 500, + order: (DEFAULT_APP_CATEGORIES.manage.order || 0) + 500, }; enum NavWidth { @@ -224,6 +82,7 @@ export function CollapsibleNavGroupEnabled({ logos, setCurrentNavGroup, capabilities, + collapsibleNavHeaderRender, ...observables }: CollapsibleNavGroupEnabledProps) { const allNavLinks = useObservable(observables.navLinks$, []); @@ -241,73 +100,119 @@ export function CollapsibleNavGroupEnabled({ [navGroupsMap, navLinks] ); - const visibleUseCases = useMemo( - () => - Object.values(navGroupsMap).filter( - (group) => group.type === undefined && group.status !== NavGroupStatus.Hidden - ), - [navGroupsMap] - ); + const visibleUseCases = useMemo(() => getVisibleUseCases(navGroupsMap), [navGroupsMap]); - const navLinksForRender: ChromeNavLink[] = useMemo(() => { - if (currentNavGroup && currentNavGroup.id !== ALL_USE_CASE_ID) { - return fulfillRegistrationLinksToChromeNavLinks( - navGroupsMap[currentNavGroup.id].navLinks || [], - navLinks - ); - } + const currentNavGroupId = useMemo(() => { + if (!currentNavGroup) { + if (visibleUseCases.length === 1) { + return visibleUseCases[0].id; + } - if (visibleUseCases.length === 1) { - return fulfillRegistrationLinksToChromeNavLinks( - navGroupsMap[visibleUseCases[0].id].navLinks || [], - navLinks - ); + if (!capabilities.workspaces.enabled) { + return ALL_USE_CASE_ID; + } } - const navLinksForAll: ChromeRegistrationNavLink[] = []; + return currentNavGroup?.id; + }, [capabilities, currentNavGroup, visibleUseCases]); + + const shouldAppendManageCategory = capabilities.workspaces.enabled + ? !currentNavGroupId + : currentNavGroupId === ALL_USE_CASE_ID; + + const shouldShowCollapsedNavHeaderContent = + isNavOpen && !!collapsibleNavHeaderRender && !currentNavGroupId; + + const navLinksForRender: ChromeNavLink[] = useMemo(() => { + const getSystemNavGroups = () => { + const result: ChromeNavLink[] = []; + Object.values(navGroupsMap) + .sort(sortBy('order')) + .forEach((navGroup) => { + if (navGroup.type !== NavGroupType.SYSTEM) { + return; + } + const visibleNavLinksWithinNavGroup = fulfillRegistrationLinksToChromeNavLinks( + navGroup.navLinks, + navLinks + ); + /** + * We will take the first visible app inside the system nav groups + * when customers click the menu. If there is not a visible nav links, + * we should not show the nav group. + */ + if (visibleNavLinksWithinNavGroup[0]) { + result.push({ + ...visibleNavLinksWithinNavGroup[0], + title: navGroup.title, + category: DEFAULT_APP_CATEGORIES.manage, + }); + } + }); + + return result; + }; + + const navLinksResult: ChromeRegistrationNavLink[] = []; - // Append all the links that do not have use case info to keep backward compatible - const linkIdsWithUseGroupInfo = Object.values(navGroupsMap).reduce((total, navGroup) => { - return [...total, ...navGroup.navLinks.map((navLink) => navLink.id)]; - }, [] as string[]); - navLinks - .filter((link) => !linkIdsWithUseGroupInfo.includes(link.id)) - .forEach((navLink) => { - navLinksForAll.push({ + if (currentNavGroupId && currentNavGroupId !== ALL_USE_CASE_ID) { + navLinksResult.push(...(navGroupsMap[currentNavGroupId].navLinks || [])); + } + + if (currentNavGroupId === ALL_USE_CASE_ID) { + // Append all the links that do not have use case info to keep backward compatible + const linkIdsWithNavGroupInfo = Object.values(navGroupsMap).reduce((total, navGroup) => { + return [...total, ...navGroup.navLinks.map((navLink) => navLink.id)]; + }, [] as string[]); + navLinks.forEach((navLink) => { + if (linkIdsWithNavGroupInfo.includes(navLink.id)) { + return; + } + navLinksResult.push({ ...navLink, category: customCategory, }); }); - // Append all the links registered to all use case - navGroupsMap[ALL_USE_CASE_ID]?.navLinks.forEach((navLink) => { - navLinksForAll.push(navLink); - }); + // Append all the links registered to all use case + navGroupsMap[ALL_USE_CASE_ID]?.navLinks.forEach((navLink) => { + navLinksResult.push(navLink); + }); - // Append use case section into left navigation - Object.values(navGroupsMap) - .filter((group) => !group.type) - .forEach((group) => { + // Append use case section into left navigation + Object.values(navGroupsMap).forEach((group) => { + if (group.type) { + return; + } const categoryInfo = { id: group.id, label: group.title, order: group.order, }; - const linksForAllUseCaseWithinNavGroup = fulfillRegistrationLinksToChromeNavLinks( + + const fulfilledLinksOfNavGroup = fulfillRegistrationLinksToChromeNavLinks( group.navLinks, navLinks - ) - .filter((navLink) => navLink.showInAllNavGroup) - .map((navLink) => ({ + ); + + const linksForAllUseCaseWithinNavGroup: ChromeRegistrationNavLink[] = []; + + fulfilledLinksOfNavGroup.forEach((navLink) => { + if (!navLink.showInAllNavGroup) { + return; + } + + linksForAllUseCaseWithinNavGroup.push({ ...navLink, category: categoryInfo, - })); + }); + }); - navLinksForAll.push(...linksForAllUseCaseWithinNavGroup); + navLinksResult.push(...linksForAllUseCaseWithinNavGroup); if (linksForAllUseCaseWithinNavGroup.length) { - navLinksForAll.push({ - id: group.navLinks[0].id, + navLinksResult.push({ + id: fulfilledLinksOfNavGroup[0].id, title: titleForSeeAll, order: Number.MAX_SAFE_INTEGER, category: categoryInfo, @@ -317,22 +222,26 @@ export function CollapsibleNavGroupEnabled({ * Find if there are any links inside a use case but without a `see all` entry. * If so, append these features into custom category as a fallback */ - fulfillRegistrationLinksToChromeNavLinks(group.navLinks, navLinks) - // Filter out links that already exists in all use case - .filter( - (navLink) => !navLinksForAll.find((navLinkInAll) => navLinkInAll.id === navLink.id) - ) - .forEach((navLink) => { - navLinksForAll.push({ - ...navLink, - category: customCategory, - }); + fulfillRegistrationLinksToChromeNavLinks(group.navLinks, navLinks).forEach((navLink) => { + // Links that already exists in all use case do not need to reappend + if (navLinksResult.find((navLinkInAll) => navLinkInAll.id === navLink.id)) { + return; + } + navLinksResult.push({ + ...navLink, + category: customCategory, }); + }); } }); + } + + if (shouldAppendManageCategory) { + navLinksResult.push(...getSystemNavGroups()); + } - return fulfillRegistrationLinksToChromeNavLinks(navLinksForAll, navLinks); - }, [navLinks, navGroupsMap, currentNavGroup, visibleUseCases]); + return fulfillRegistrationLinksToChromeNavLinks(navLinksResult, navLinks); + }, [navLinks, navGroupsMap, currentNavGroupId, shouldAppendManageCategory]); const width = useMemo(() => { if (!isNavOpen) { @@ -398,7 +307,7 @@ export function CollapsibleNavGroupEnabled({ navigateToApp={navigateToApp} logos={logos} setCurrentNavGroup={setCurrentNavGroup} - currentNavGroup={currentNavGroup} + currentNavGroup={currentNavGroupId ? navGroupsMap[currentNavGroupId] : undefined} shouldShrinkNavigation={!isNavOpen} onClickShrink={closeNav} visibleUseCases={visibleUseCases} @@ -414,6 +323,12 @@ export function CollapsibleNavGroupEnabled({ hasShadow={false} className="eui-yScroll flex-1-container" > + {shouldShowCollapsedNavHeaderContent && collapsibleNavHeaderRender ? ( + <> + {collapsibleNavHeaderRender()} + + + ) : null} ', () => { currentWorkspace$: new BehaviorSubject({ id: 'foo', name: 'foo' }), visibleUseCases: [ { - id: 'navGroupFoo', + id: ALL_USE_CASE_ID, title: 'navGroupFoo', description: 'navGroupFoo', navLinks: [], }, - { - id: 'navGroupBar', - title: 'navGroupBar', - description: 'navGroupBar', - navLinks: [], - }, ], currentNavGroup: { id: 'navGroupFoo', diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx index 23e2f7e6108c..067fb2ffd2e1 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx @@ -56,7 +56,9 @@ export const CollapsibleNavTop = ({ * 3. current nav group is not all use case */ const isInsideSecondLevelOfAllWorkspace = - visibleUseCases.length > 1 && !!currentWorkspace && currentNavGroup?.id !== ALL_USE_CASE_ID; + !!currentWorkspace && + visibleUseCases[0].id === ALL_USE_CASE_ID && + currentNavGroup?.id !== ALL_USE_CASE_ID; const shouldShowBackButton = !shouldShrinkNavigation && isInsideSecondLevelOfAllWorkspace; const shouldShowHomeLink = !shouldShrinkNavigation && !shouldShowBackButton; diff --git a/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx new file mode 100644 index 000000000000..75865190cad8 --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { NavGroups } from './collapsible_nav_groups'; +import { ChromeRegistrationNavLink } from '../../nav_group'; +import { ChromeNavLink } from '../../nav_links'; + +describe('', () => { + const getMockedNavLink = ( + navLink: Partial + ): ChromeNavLink & ChromeRegistrationNavLink => ({ + baseUrl: '', + href: '', + id: '', + title: '', + ...navLink, + }); + it('should render correctly', () => { + const navigateToApp = jest.fn(); + const onNavItemClick = jest.fn(); + const { container, getByTestId, queryByTestId } = render( + + ); + expect(container).toMatchSnapshot(); + expect(container.querySelectorAll('.nav-link-item-btn').length).toEqual(5); + fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); + expect(navigateToApp).toBeCalledTimes(0); + // The accordion is collapsed + expect(queryByTestId('collapsibleNavAppLink-subLink')).toBeNull(); + + // Expand the accordion + fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); + fireEvent.click(getByTestId('collapsibleNavAppLink-subLink')); + expect(navigateToApp).toBeCalledWith('subLink'); + }); +}); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx b/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx new file mode 100644 index 000000000000..53a75aeaaddd --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx @@ -0,0 +1,150 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './collapsible_nav_group_enabled.scss'; +import { EuiFlexItem, EuiSideNavItemType, EuiSideNav, EuiText } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React from 'react'; +import classNames from 'classnames'; +import { ChromeNavLink } from '../..'; +import { InternalApplicationStart } from '../../../application/types'; +import { createEuiListItem } from './nav_link'; +import { getOrderedLinksOrCategories, LinkItem, LinkItemType } from '../../utils'; + +export interface NavGroupsProps { + navLinks: ChromeNavLink[]; + suffix?: React.ReactElement; + style?: React.CSSProperties; + appId?: string; + navigateToApp: InternalApplicationStart['navigateToApp']; + onNavItemClick: ( + event: React.MouseEvent, + navItem: ChromeNavLink + ) => void; +} + +const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { + defaultMessage: 'See all...', +}); + +const LEVEL_FOR_ROOT_ITEMS = 1; + +export function NavGroups({ + navLinks, + suffix, + style, + appId, + navigateToApp, + onNavItemClick, +}: NavGroupsProps) { + const createNavItem = ({ + link, + className, + }: { + link: ChromeNavLink; + className?: string; + }): EuiSideNavItemType<{}> => { + const euiListItem = createEuiListItem({ + link, + appId, + dataTestSubj: `collapsibleNavAppLink-${link.id}`, + navigateToApp, + onClick: (event) => { + onNavItemClick(event, link); + }, + }); + + return { + id: `${link.id}-${link.title}`, + name: {link.title}, + onClick: euiListItem.onClick, + href: euiListItem.href, + emphasize: euiListItem.isActive, + className: `nav-link-item ${className || ''}`, + buttonClassName: 'nav-link-item-btn', + 'data-test-subj': euiListItem['data-test-subj'], + 'aria-label': link.title, + }; + }; + const createSideNavItem = ( + navLink: LinkItem, + level: number, + className?: string + ): EuiSideNavItemType<{}> => { + if (navLink.itemType === LinkItemType.LINK) { + if (navLink.link.title === titleForSeeAll) { + const navItem = createNavItem({ + link: navLink.link, + }); + + return { + ...navItem, + name: {navItem.name}, + emphasize: false, + }; + } + + return createNavItem({ + link: navLink.link, + className, + }); + } + + if (navLink.itemType === LinkItemType.PARENT_LINK && navLink.link) { + const props = createNavItem({ link: navLink.link }); + const parentItem = { + ...props, + forceOpen: true, + /** + * The href and onClick should both be undefined to make parent item rendered as accordion. + */ + href: undefined, + onClick: undefined, + className: classNames(props.className, 'nav-link-parent-item'), + buttonClassName: classNames(props.buttonClassName, 'nav-link-parent-item-button'), + items: navLink.links.map((subNavLink) => + createSideNavItem(subNavLink, level + 1, 'nav-nested-item') + ), + }; + /** + * OuiSideBar will never render items of first level as accordion, + * in order to display accordion, we need to render a fake parent item. + */ + if (level === LEVEL_FOR_ROOT_ITEMS) { + return { + className: 'nav-link-fake-item', + buttonClassName: 'nav-link-fake-item-button', + name: '', + items: [parentItem], + id: `fake_${props.id}`, + }; + } + + return parentItem; + } + + if (navLink.itemType === LinkItemType.CATEGORY) { + return { + id: navLink.category?.id ?? '', + name:
{navLink.category?.label ?? ''}
, + items: navLink.links?.map((link) => createSideNavItem(link, level + 1)), + 'aria-label': navLink.category?.label, + }; + } + + return {} as EuiSideNavItemType<{}>; + }; + const orderedLinksOrCategories = getOrderedLinksOrCategories(navLinks); + const sideNavItems = orderedLinksOrCategories + .map((navLink) => createSideNavItem(navLink, LEVEL_FOR_ROOT_ITEMS)) + .filter((navItem) => !!navItem.id); + + return ( + + + {suffix} + + ); +} diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 9cc8652c3e41..7edf893826ac 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -194,24 +194,6 @@ describe('Header', () => { expect(component.find('CollapsibleNavGroupEnabled').exists()).toBeTruthy(); }); - it('show hide expand icon in top left navigation when workspace enabled + homepage + new navigation enabled', () => { - const branding = { - useExpandedHeader: false, - }; - const props = { - ...mockProps(), - branding, - }; - props.application.currentAppId$ = new BehaviorSubject('home'); - props.application.capabilities = { ...props.application.capabilities }; - (props.application.capabilities.workspaces as Record) = {}; - (props.application.capabilities.workspaces as Record).enabled = true; - - const component = mountWithIntl(
); - - expect(component.find('.header__toggleNavButtonSection').exists()).toBeFalsy(); - }); - it('toggles primary navigation menu when clicked', () => { const branding = { useExpandedHeader: false, diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 492d9c7b3e78..6fa8d9a43a75 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -138,18 +138,10 @@ export function Header({ const isVisible = useObservable(observables.isVisible$, false); const headerVariant = useObservable(observables.headerVariant$, HeaderVariant.PAGE); const isLocked = useObservable(observables.isLocked$, false); - const appId = useObservable(application.currentAppId$, ''); const [isNavOpen, setIsNavOpen] = useState(false); const sidecarConfig = useObservable(observables.sidecarConfig$, undefined); const breadcrumbs = useObservable(observables.breadcrumbs$, []); - /** - * This is a workaround on 2.16 to hide the navigation items within left navigation - * when user is in homepage with workspace enabled + new navigation enabled - */ - const shouldHideExpandIcon = - navGroupEnabled && appId === 'home' && application.capabilities.workspaces.enabled; - const sidecarPaddingStyle = useMemo(() => { return getOsdSidecarPaddingStyle(sidecarConfig); }, [sidecarConfig]); @@ -365,11 +357,9 @@ export function Header({ const renderLegacyHeader = () => ( - {shouldHideExpandIcon ? null : ( - - {renderNavToggle()} - - )} + + {renderNavToggle()} + {renderLeftControls()} @@ -402,7 +392,7 @@ export function Header({ const renderPageHeader = () => (
- {shouldHideExpandIcon || isNavOpen ? null : renderNavToggle()} + {isNavOpen ? null : renderNavToggle()} {renderRecentItems()} @@ -450,7 +440,7 @@ export function Header({ const renderApplicationHeader = () => (
- {shouldHideExpandIcon || isNavOpen ? null : renderNavToggle()} + {isNavOpen ? null : renderNavToggle()} {renderRecentItems()} {renderActionMenu()} @@ -475,10 +465,11 @@ export function Header({ {navGroupEnabled ? ( = keyof T; -const sortBy = (key: KeyOf) => { +export const sortBy = (key: KeyOf) => { return (a: T, b: T): number => (a[key] > b[key] ? 1 : b[key] > a[key] ? -1 : 0); }; @@ -214,3 +215,9 @@ export const getSortedNavLinks = ( ); return acc; }; + +export const getVisibleUseCases = (navGroupMap: Record) => { + return Object.values(navGroupMap).filter( + (navGroup) => navGroup.status !== NavGroupStatus.Hidden && navGroup.type === undefined + ); +}; diff --git a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts index 3cab62478cbc..eaa5e14538af 100644 --- a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts +++ b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts @@ -37,6 +37,7 @@ import { Env } from '../../config'; import { getEnvOptions } from '../../config/mocks'; import { CapabilitiesService, CapabilitiesSetup } from '..'; import { createHttpServer } from '../../http/test_utils'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; const coreId = Symbol('core'); @@ -59,9 +60,12 @@ describe('CapabilitiesService', () => { env, logger: loggingSystemMock.create(), configService: {} as any, + dynamicConfigService: dynamicConfigServiceMock.create(), }); serviceSetup = await service.setup({ http: httpSetup }); - await server.start(); + await server.start({ + dynamicConfigService: dynamicConfigServiceMock.createInternalStartContract(), + }); }); afterEach(async () => { diff --git a/src/core/server/config/dynamic_config_service.mock.ts b/src/core/server/config/dynamic_config_service.mock.ts new file mode 100644 index 000000000000..c4665fd8d320 --- /dev/null +++ b/src/core/server/config/dynamic_config_service.mock.ts @@ -0,0 +1,96 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IDynamicConfigService } from './dynamic_config_service'; +import { + DynamicConfigurationClientMockProps, + dynamicConfigurationClientMock, +} from './service/configuration_client.mock'; +import { + AsyncLocalStorageContext, + DynamicConfigServiceSetup, + DynamicConfigServiceStart, + InternalDynamicConfigServiceSetup, + InternalDynamicConfigServiceStart, +} from './types'; + +const createDynamicConfigServiceMock = ( + mockClientReturnValues?: DynamicConfigurationClientMockProps, + mockAsyncLocalStoreValues?: AsyncLocalStorageContext +) => { + const mocked: jest.Mocked = { + setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), + start: jest + .fn() + .mockReturnValue( + createInternalStartContractMock(mockClientReturnValues, mockAsyncLocalStoreValues) + ), + stop: jest.fn(), + setSchema: jest.fn(), + hasDefaultConfigs: jest.fn(), + registerRoutesAndHandlers: jest.fn(), + }; + + return mocked; +}; + +const createSetupContractMock = () => { + const mocked: jest.Mocked = { + registerDynamicConfigClientFactory: jest.fn(), + registerAsyncLocalStoreRequestHeader: jest.fn(), + getStartService: jest.fn(), + }; + + return mocked; +}; +const createInternalSetupContractMock = () => { + const mocked: jest.Mocked = { + registerDynamicConfigClientFactory: jest.fn(), + registerAsyncLocalStoreRequestHeader: jest.fn(), + getStartService: jest.fn(), + }; + + return mocked; +}; +const createStartContractMock = ( + mockClientReturnValues?: DynamicConfigurationClientMockProps, + mockAsyncLocalStoreValues?: AsyncLocalStorageContext +) => { + const client = mockClientReturnValues + ? dynamicConfigurationClientMock.create(mockClientReturnValues) + : dynamicConfigurationClientMock.create(); + + const mocked: jest.Mocked = { + getClient: jest.fn().mockReturnValue(client), + getAsyncLocalStore: jest.fn().mockReturnValue(mockAsyncLocalStoreValues), + createStoreFromRequest: jest.fn().mockRejectedValue(mockAsyncLocalStoreValues), + }; + + return mocked; +}; +const createInternalStartContractMock = ( + mockClientReturnValues?: DynamicConfigurationClientMockProps, + mockAsyncLocalStoreValues?: AsyncLocalStorageContext +) => { + const client = mockClientReturnValues + ? dynamicConfigurationClientMock.create(mockClientReturnValues) + : dynamicConfigurationClientMock.create(); + + const mocked: jest.Mocked = { + getClient: jest.fn().mockReturnValue(client), + getAsyncLocalStore: jest.fn().mockReturnValue(mockAsyncLocalStoreValues), + createStoreFromRequest: jest.fn().mockRejectedValue(mockAsyncLocalStoreValues), + }; + + return mocked; +}; + +export const dynamicConfigServiceMock = { + create: createDynamicConfigServiceMock, + createInternalSetupContract: createInternalSetupContractMock, + createInternalStartContract: createInternalStartContractMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/server/config/dynamic_config_service.test.ts b/src/core/server/config/dynamic_config_service.test.ts new file mode 100644 index 000000000000..6dbbeb4cfe79 --- /dev/null +++ b/src/core/server/config/dynamic_config_service.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DynamicConfigService, IDynamicConfigService } from './dynamic_config_service'; +import { configServiceMock, httpServiceMock, opensearchServiceMock } from '../mocks'; +import { loggerMock } from '../logging/logger.mock'; +import { LoggerFactory } from '@osd/logging'; +import { schema, Type } from '@osd/config-schema'; +import { IDynamicConfigStoreClient } from 'opensearch-dashboards/server'; + +describe('DynamicConfigService', () => { + let dynamicConfigService: IDynamicConfigService; + const openSearchMock = opensearchServiceMock.createStart(); + + beforeEach(() => { + const loggerFactoryMock = {} as LoggerFactory; + loggerFactoryMock.get = jest.fn().mockReturnValue(loggerMock.create()); + + dynamicConfigService = new DynamicConfigService( + configServiceMock.create(), + {} as any, + loggerFactoryMock + ); + }); + + it('setup() and start() should return the same clients/async local stores', async () => { + const dynamicConfigServiceSetup = await dynamicConfigService.setup(); + expect(dynamicConfigServiceSetup.getStartService()).toBeDefined(); + expect(dynamicConfigServiceSetup.registerDynamicConfigClientFactory).toBeDefined(); + expect(dynamicConfigServiceSetup.registerAsyncLocalStoreRequestHeader).toBeDefined(); + + dynamicConfigServiceSetup.registerDynamicConfigClientFactory({ + create: () => { + return {} as IDynamicConfigStoreClient; + }, + }); + + const dynamicConfigServiceStart = await dynamicConfigService.start({ + opensearch: openSearchMock, + }); + expect(dynamicConfigServiceStart.getAsyncLocalStore).toBeDefined(); + expect(dynamicConfigServiceStart.getClient()).toBeDefined(); + + const actualGetStartServices = await dynamicConfigServiceSetup.getStartService(); + + expect(actualGetStartServices.getClient()).toMatchObject(dynamicConfigServiceStart.getClient()); + expect(actualGetStartServices.getAsyncLocalStore).toBeDefined(); + }); + + describe('After http is setup', () => { + it('setupHTTP() should add the async local store preAuth middleware', () => { + const httpSetupMock = httpServiceMock.createInternalSetupContract(); + dynamicConfigService.registerRoutesAndHandlers({ http: httpSetupMock }); + expect(httpSetupMock.registerOnPostAuth).toHaveBeenCalled(); + }); + }); + + it('setSchema() and hasDefaultConfigs() should set and check if schemas have been registered', () => { + const schemaList: Map> = new Map(); + + schemaList.set( + 'foo', + schema.object({ + a: schema.boolean(), + b: schema.object({ + c: schema.string(), + }), + }) + ); + schemaList.set( + 'bar', + schema.object({ + a: schema.boolean(), + b: schema.boolean(), + c: schema.object({ + d: schema.string(), + }), + }) + ); + schemaList.set( + 'baz', + schema.object({ + a: schema.object({ + c: schema.object({ + d: schema.boolean(), + }), + }), + }) + ); + + schemaList.forEach((value, key) => { + dynamicConfigService.setSchema(key, value); + }); + + [...schemaList.keys()].forEach((key) => { + expect(dynamicConfigService.hasDefaultConfigs({ name: key })).toBe(true); + }); + + expect(dynamicConfigService.hasDefaultConfigs({ name: 'nonexistent_config' })).toBe(false); + }); +}); diff --git a/src/core/server/config/dynamic_config_service.ts b/src/core/server/config/dynamic_config_service.ts new file mode 100644 index 000000000000..bb83ecdb3167 --- /dev/null +++ b/src/core/server/config/dynamic_config_service.ts @@ -0,0 +1,214 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ConfigPath, Env, IConfigService } from '@osd/config'; +import { Type } from '@osd/config-schema'; +import { PublicMethodsOf } from '@osd/utility-types'; +import { AsyncLocalStorage } from 'async_hooks'; +import { first } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { InternalHttpServiceSetup, OpenSearchDashboardsRequest } from '../http'; +import { CoreService } from '../../types'; +import { + AsyncLocalStorageContext, + ConfigIdentifier, + IDynamicConfigStoreClient, + IDynamicConfigStoreClientFactory, + InternalDynamicConfigServiceSetup, + InternalDynamicConfigServiceStart, +} from './types'; +import { InternalDynamicConfigurationClient } from './service/internal_dynamic_configuration_client'; +import { Logger, LoggerFactory } from '../logging'; +import { createLocalStoreFromOsdRequest, pathToString } from './utils/utils'; +import { DynamicConfigurationClient } from './service/dynamic_configuration_client'; +import { OpenSearchDynamicConfigStoreFactory } from './service/config_store_client/opensearch_config_store_factory'; +import { InternalOpenSearchServiceStart } from '../opensearch'; +import { DynamicConfigServiceConfigType } from './dynamic_config_service_config'; +import { DummyDynamicConfigStoreFactory } from './service/config_store_client/dummy_config_store_factory'; + +export interface RegisterHTTPSetupDeps { + http: InternalHttpServiceSetup; +} + +export interface StartDeps { + opensearch: InternalOpenSearchServiceStart; +} + +export type IDynamicConfigService = PublicMethodsOf; + +/** @internal */ +export class DynamicConfigService + implements CoreService { + readonly #configService: IConfigService; + readonly #envService: Env; + readonly #logger: Logger; + readonly #schemas = new Map>(); + readonly #config$: Observable; + readonly #asyncLocalStorage: AsyncLocalStorage< + AsyncLocalStorageContext + > = new AsyncLocalStorage(); + readonly #requestHeaders: string[] = []; + #configStoreClientFactory?: IDynamicConfigStoreClientFactory; + #started = false; + #startPromiseResolver?: (startServices: InternalDynamicConfigServiceStart) => void; + readonly #startPromise: Promise; + + constructor(configService: IConfigService, envService: Env, logger: LoggerFactory) { + this.#configService = configService; + this.#envService = envService; + this.#logger = logger.get('dynamic-config-service'); + this.#startPromise = new Promise( + (resolve) => (this.#startPromiseResolver = resolve) + ); + this.#config$ = configService + .atPath('dynamic_config_service') + .pipe(first()); + } + public async setup(): Promise { + return { + registerDynamicConfigClientFactory: (factory: IDynamicConfigStoreClientFactory) => { + if (this.#configStoreClientFactory) { + throw new Error('Dynamic config store client factory is already set'); + } + if (this.#started) { + throw new Error( + 'Cannot set config store client factory because dynamic configuration service has already started' + ); + } + this.#configStoreClientFactory = factory; + }, + registerAsyncLocalStoreRequestHeader: (key: string | string[]) => { + if (typeof key === 'string') { + this.#requestHeaders.push(key); + } else { + this.#requestHeaders.push(...key); + } + }, + getStartService: async () => { + return await this.#startPromise; + }, + }; + } + + public async start({ opensearch }: StartDeps): Promise { + this.#logger.info('initiating start()'); + const config = await this.#config$.pipe(first()).toPromise(); + let configStoreClient: IDynamicConfigStoreClient; + + if (!config.enabled) { + const dummyDynamicConfigStoreClientFactory = new DummyDynamicConfigStoreFactory(); + configStoreClient = dummyDynamicConfigStoreClientFactory.create(); + } else { + if (this.#configStoreClientFactory) { + configStoreClient = this.#configStoreClientFactory.create(); + } else { + const defaultDynamicConfigStoreClientFactory = new OpenSearchDynamicConfigStoreFactory( + opensearch + ); + const defaultConfigStoreClient = defaultDynamicConfigStoreClientFactory.create(); + if (!config.skipMigrations) { + await defaultConfigStoreClient.createDynamicConfigIndex(); + } + configStoreClient = defaultConfigStoreClient; + } + } + + // Create the clients + const internalClient = new InternalDynamicConfigurationClient({ + client: configStoreClient, + configService: this.#configService, + env: this.#envService, + logger: this.#logger, + schemas: this.#schemas, + }); + const client = new DynamicConfigurationClient(internalClient); + + const startServices: InternalDynamicConfigServiceStart = { + getClient: () => { + return client; + }, + getAsyncLocalStore: () => { + return this.#asyncLocalStorage.getStore(); + }, + createStoreFromRequest: (request: OpenSearchDashboardsRequest) => { + return createLocalStoreFromOsdRequest(this.#logger, request, this.#requestHeaders); + }, + }; + + this.#logger.info('finished start()'); + this.#started = true; + if (this.#startPromiseResolver) { + this.#startPromiseResolver(startServices); + } + + return startServices; + } + + /** + * Extra setup step to register any HTTP routes and the async local store. This should be called after all plugins are setup but before dynamicConfigService is started + * + * @param setupDeps + */ + public async registerRoutesAndHandlers(setupDeps: RegisterHTTPSetupDeps) { + const { http } = setupDeps; + + /** + * TODO Register the routes + * - validate (needed for CP) + * - Optional: + * - create + * - bulkCreate + * - get + * - bulkGet + * - list + * - delete + * - bulkDelete + */ + + // FIXME: This seems not working as expected, as sometimes the context is not available to request handlers after registering + // in the PostAuth handler. Needs to do more research. + // For now, we can use DynamicConfigService.createStoreFromRequest(request) to create context store when it needs to + // fetch configrations from DynamicConfigStore. + this.#logger.info('registering middleware to inject context to AsyncLocalStorage'); + http.registerOnPostAuth((request, response, context) => { + if (request.auth.isAuthenticated) { + const localStore = createLocalStoreFromOsdRequest( + this.#logger, + request, + this.#requestHeaders + ) as AsyncLocalStorageContext; + this.#asyncLocalStorage.enterWith(localStore); + } + return context.next(); + }); + } + + public async stop() {} + + /** + * Mimics Config Service schema registration, which should be finished calling before start() is called. Validation is not needed as the Config Service handles that + * + * @param path {string} the core ID, plugin ID, or the plugin configPath (if specified) + * @param schema {Type} the schema object defined in config.ts + */ + public setSchema(path: ConfigPath, schema: Type) { + // Even though server configs are not pluginConfigPaths, the logic to parse the namespace will not change + const namespace = pathToString({ pluginConfigPath: path }); + if (this.#schemas.has(namespace)) { + throw new Error(`Validation schema for [${namespace}] was already registered.`); + } + this.#schemas.set(namespace, schema); + } + + /** + * Checks if a certain config already exists + * + * @param configIdentifier {ConfigIdentifier} the core ID, plugin ID, or the plugin configPath (if specified) + */ + public hasDefaultConfigs(configIdentifier: ConfigIdentifier) { + const namespace = pathToString(configIdentifier); + return this.#schemas.has(namespace); + } +} diff --git a/src/core/server/config/dynamic_config_service_config.ts b/src/core/server/config/dynamic_config_service_config.ts new file mode 100644 index 000000000000..6a2762916ec7 --- /dev/null +++ b/src/core/server/config/dynamic_config_service_config.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; +import { ServiceConfigDescriptor } from '../internal_types'; + +export type DynamicConfigServiceConfigType = TypeOf; + +export const configSchema = schema.object({ + skipMigrations: schema.boolean({ defaultValue: false }), + // If not enabled, the core service will exist but the client returned will just return static configs + enabled: schema.boolean({ defaultValue: false }), +}); + +export const config: ServiceConfigDescriptor = { + path: 'dynamic_config_service', + schema: configSchema, +}; diff --git a/src/core/server/config/index.ts b/src/core/server/config/index.ts index 7951d0cbd05e..e89f608b9816 100644 --- a/src/core/server/config/index.ts +++ b/src/core/server/config/index.ts @@ -30,6 +30,10 @@ export { coreDeprecationProvider } from './deprecation'; +export { dynamicConfigServiceMock } from './mocks'; + +export { config } from './dynamic_config_service_config'; + export { ConfigService, IConfigService, @@ -50,3 +54,5 @@ export { PackageInfo, LegacyObjectToConfigAdapter, } from '@osd/config'; + +export * from './types'; diff --git a/src/core/server/config/mocks.ts b/src/core/server/config/mocks.ts index 0ed455454999..372b5c980e1a 100644 --- a/src/core/server/config/mocks.ts +++ b/src/core/server/config/mocks.ts @@ -34,3 +34,5 @@ export { configServiceMock, configMock, } from '@osd/config/target/mocks'; + +export { dynamicConfigServiceMock } from './dynamic_config_service.mock'; diff --git a/src/core/server/config/service/config_store_client/dummy_config_store_client.ts b/src/core/server/config/service/config_store_client/dummy_config_store_client.ts new file mode 100644 index 000000000000..c81839e34662 --- /dev/null +++ b/src/core/server/config/service/config_store_client/dummy_config_store_client.ts @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BulkCreateConfigProps, + BulkDeleteConfigProps, + ConfigIdentifier, + CreateConfigProps, + DynamicConfigurationClientOptions, + IDynamicConfigStoreClient, +} from '../../types'; +import { dynamicConfigurationClientMock } from '../configuration_client.mock'; + +/** + * The DummyConfigStoreClient is the client DAO that will used when dynamic config service is "disabled". + * The client will return nothing, which will cause the dynamic config service to return static configs only. + * It is important to note that the DynamicConfigService will always exist as it's a core service. + */ +export class DummyConfigStoreClient implements IDynamicConfigStoreClient { + public async listConfigs(options?: DynamicConfigurationClientOptions | undefined) { + return Promise.resolve(new Map()); + } + + public async bulkCreateConfigs( + bulkCreateConfigProps: BulkCreateConfigProps, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(dynamicConfigurationClientMock.createApiResponse()); + } + + public async createConfig( + createConfigProps: CreateConfigProps, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(dynamicConfigurationClientMock.createApiResponse()); + } + + public async bulkDeleteConfigs( + bulkDeleteConfigs: BulkDeleteConfigProps, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(dynamicConfigurationClientMock.createApiResponse()); + } + + public async deleteConfig( + deleteConfigs: ConfigIdentifier, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(dynamicConfigurationClientMock.createApiResponse()); + } + + public async getConfig( + namespace: string, + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(undefined); + } + + public async bulkGetConfigs( + namespaces: string[], + options?: DynamicConfigurationClientOptions | undefined + ) { + return Promise.resolve(new Map()); + } +} diff --git a/src/core/server/config/service/config_store_client/dummy_config_store_factory.ts b/src/core/server/config/service/config_store_client/dummy_config_store_factory.ts new file mode 100644 index 000000000000..494d355ebde2 --- /dev/null +++ b/src/core/server/config/service/config_store_client/dummy_config_store_factory.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IDynamicConfigStoreClientFactory } from 'opensearch-dashboards/server'; +import { DummyConfigStoreClient } from './dummy_config_store_client'; + +export class DummyDynamicConfigStoreFactory implements IDynamicConfigStoreClientFactory { + public create() { + return new DummyConfigStoreClient(); + } +} diff --git a/src/core/server/config/service/config_store_client/opensearch_config_store.test.ts b/src/core/server/config/service/config_store_client/opensearch_config_store.test.ts new file mode 100644 index 000000000000..df34a3cc7867 --- /dev/null +++ b/src/core/server/config/service/config_store_client/opensearch_config_store.test.ts @@ -0,0 +1,610 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SearchResponse } from '../../../opensearch'; +import { opensearchClientMock } from '../../../opensearch/client/mocks'; +import { DYNAMIC_APP_CONFIG_ALIAS } from '../../utils/constants'; +import { OpenSearchConfigStoreClient } from './opensearch_config_store_client'; +import { ConfigDocument } from './types'; +import _ from 'lodash'; +import { ConfigBlob } from '../../types'; +import { BulkOperationContainer } from '@opensearch-project/opensearch/api/types'; +import { getDynamicConfigIndexName } from '../../utils/utils'; + +describe('OpenSearchConfigStoreClient', () => { + /** + * Helper function to assert general map equality + * + * @param map1 + * @param map2 + */ + const assertMapsAreEqual = (map1: Map, map2: Map) => { + expect(map1.size).toBe(map2.size); + + map1.forEach((value, key) => { + expect(map2.get(key)).toMatchObject(value); + }); + }; + + interface OpenSearchClientMockProps { + isListConfig: boolean; + configDocuments: ConfigDocument[]; + existsAliasResult: boolean; + } + + /** + * Creates a new OpenSearch client mock complete with a mock for existsAlias() and search() results + * + * @param param0 + * @returns + */ + const createOpenSearchClientMock = ({ + isListConfig, + configDocuments, + existsAliasResult, + }: OpenSearchClientMockProps) => { + const mockClient = opensearchClientMock.createOpenSearchClient(); + + mockClient.indices.existsAlias.mockResolvedValue( + opensearchClientMock.createApiResponse({ + body: existsAliasResult as any, + }) + ); + + // @ts-expect-error + mockClient.search.mockImplementation((request, options) => { + // Filters out results when the request is for getting/bulk getting configs + const mockHits = isListConfig + ? configDocuments + : configDocuments.filter((configDocument) => { + // @ts-expect-error + const namespaces: string[] = request!.body!.query!.bool!.should![0].terms.config_name; + return namespaces.includes(configDocument.config_name); + }); + + return Promise.resolve( + opensearchClientMock.createApiResponse>({ + body: { + hits: { + hits: mockHits.map((hit) => ({ + _index: getDynamicConfigIndexName(1), + _id: JSON.stringify(hit), + _source: hit, + })), + }, + }, + }) + ); + }); + + return mockClient; + }; + + const configDocument: ConfigDocument = { + config_name: 'some_config_name', + config_blob: { + foo: { + bar: 1, + }, + baz: 'value', + }, + }; + const configDocuments: ConfigDocument[] = [ + { + config_name: 'config_a', + config_blob: { + level1: { + name: 'Object1', + value: 10, + level2: { + description: 'This is level 2 of object 1', + }, + }, + }, + }, + { + config_name: 'config_b', + config_blob: { + levelA: { + flag: true, + levelB: { + items: ['item1', 'item2', 'item3'], + levelC: { + count: 3, + }, + }, + }, + }, + }, + { + config_name: 'config_c', + config_blob: { + section: { + id: 'sec1', + levelX: { + title: 'Section Title', + levelY: { + status: 'active', + }, + }, + }, + }, + }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createDynamicConfigIndex()', () => { + it.each([ + { + existsAliasResult: false, + numCreateCalls: 1, + }, + { + existsAliasResult: true, + numCreateCalls: 0, + }, + ])( + 'should create config index $numCreateCalls times when existsAlias() is $existsAliasResult', + async ({ existsAliasResult, numCreateCalls }) => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: [], + existsAliasResult, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.createDynamicConfigIndex(); + + expect(mockClient.indices.existsAlias).toBeCalled(); + expect(mockClient.indices.create).toBeCalledTimes(numCreateCalls); + expect(mockClient.indices.updateAliases).toBeCalledTimes(numCreateCalls); + } + ); + }); + + describe('getConfig()', () => { + const expectedConfigBlob = _.cloneDeep(configDocument.config_blob); + + it('should use cache when necessary and return correct config', async () => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: [configDocument], + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + + // Testing cache + for (let i = 0; i < 2; i++) { + const result = await configStoreClient.getConfig('some_config_name'); + expect(result).toMatchObject(expectedConfigBlob); + expect(mockClient.search).toBeCalledTimes(1); + } + + // Clearing cache should induce another search call + configStoreClient.clearCache(); + const result3 = await configStoreClient.getConfig('some_config_name'); + expect(result3).toMatchObject(expectedConfigBlob); + expect(mockClient.search).toBeCalledTimes(2); + + const nonExistentResult = await configStoreClient.getConfig('non_existent_config'); + expect(nonExistentResult).not.toBeDefined(); + expect(mockClient.search).toBeCalledTimes(3); + }); + }); + + describe('bulkGetConfigs()', () => { + const expectedConfigMap = new Map( + configDocuments.map((document) => [document.config_name, document.config_blob]) + ); + const createPartialExpectedConfigMap = (namesToKeep: string[]) => { + return new Map([...expectedConfigMap].filter(([name, config]) => namesToKeep.includes(name))); + }; + + it('should use cache when necessary and return correct configs', async () => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments, + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + + const partialNames = ['config_a', 'config_b']; + const expectedConfigMapPartial = createPartialExpectedConfigMap(partialNames); + + for (let i = 0; i < 2; i++) { + const results = await configStoreClient.bulkGetConfigs(partialNames); + assertMapsAreEqual(expectedConfigMapPartial, results); + expect(mockClient.search).toBeCalledTimes(1); + } + + // Partial cache hit initially, results are searched and found + for (let i = 0; i < 2; i++) { + const results = await configStoreClient.bulkGetConfigs([...partialNames, 'config_c']); + assertMapsAreEqual(expectedConfigMap, results); + expect(mockClient.search).toBeCalledTimes(2); + } + + // Partial results + for (let i = 0; i < 2; i++) { + const results = await configStoreClient.bulkGetConfigs([ + ...partialNames, + 'non_existent_config', + ]); + assertMapsAreEqual(expectedConfigMapPartial, results); + expect(mockClient.search).toBeCalledTimes(3); + } + + // No results + for (let i = 0; i < 2; i++) { + const results = await configStoreClient.bulkGetConfigs([ + 'non_existent_config', + 'other_nonexistent_config', + ]); + assertMapsAreEqual(new Map(), results); + expect(mockClient.search).toBeCalledTimes(4); + } + }); + }); + + describe('listConfigs', () => { + it.each([ + { + allConfigDocuments: [], + expectedMap: new Map(), + }, + { + allConfigDocuments: [configDocument], + expectedMap: new Map([[configDocument.config_name, configDocument.config_blob]]), + }, + { + allConfigDocuments: configDocuments, + expectedMap: new Map( + configDocuments.map((document) => [document.config_name, document.config_blob]) + ), + }, + ])( + 'should return a Map containing $configDocuments.length configs', + async ({ allConfigDocuments, expectedMap }) => { + const mockClient = createOpenSearchClientMock({ + isListConfig: true, + configDocuments: allConfigDocuments, + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + const actualMap = await configStoreClient.listConfigs(); + + assertMapsAreEqual(actualMap, expectedMap); + } + ); + }); + + describe('createConfig()', () => { + const itemId = 'some_item_id'; + const updatedConfigBlob = { + foo: { + bar: 5, + }, + foobar: ['new', 'config'], + }; + + it.each([ + { + newConfigDocuments: [], + newConfigBlob: updatedConfigBlob, + expectedBulkRequest: [ + { + create: { + _id: itemId, + }, + }, + { + config_name: 'some_config_name', + config_blob: updatedConfigBlob, + }, + ], + }, + { + newConfigDocuments: [configDocument], + newConfigBlob: updatedConfigBlob, + expectedBulkRequest: [ + { + update: { + _id: JSON.stringify(configDocument), + }, + }, + { + doc: { + config_blob: updatedConfigBlob, + }, + }, + ], + }, + ])( + 'should call bulk() with correct operations', + async ({ newConfigBlob, newConfigDocuments, expectedBulkRequest }) => { + jest.spyOn(_, 'uniqueId').mockImplementation(() => itemId); + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: newConfigDocuments, + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.createConfig({ + config: { + name: 'some_config_name', + updatedConfig: newConfigBlob, + }, + }); + + expect(mockClient.bulk).toBeCalledWith({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: expectedBulkRequest, + }); + + // Should cache result (search() is always called before bulk() to find existing configs) + const result = await configStoreClient.getConfig('some_config_name'); + expect(result).toMatchObject(newConfigBlob); + expect(mockClient.search).toBeCalledTimes(1); + } + ); + }); + + describe('bulkCreateConfigs()', () => { + const spyFunction = jest.spyOn(_, 'uniqueId'); + + interface BulkCreateConfigTestCaseFormat { + configsToCreate: ConfigDocument[]; + configsToUpdate: ConfigDocument[]; + existingConfigs: ConfigDocument[]; + } + it.each([ + { + configsToCreate: [configDocument], + existingConfigs: [], + configsToUpdate: [], + }, + { + configsToCreate: [], + existingConfigs: [configDocument], + configsToUpdate: [ + { + ...configDocument, + config_blob: { + foo: { + bar: 5, + }, + foobar: ['new', 'values'], + }, + }, + ], + }, + { + configsToCreate: configDocuments, + existingConfigs: [], + configsToUpdate: [], + }, + { + configsToCreate: [configDocument], + existingConfigs: configDocuments.slice(0, 2), + configsToUpdate: [ + { + ...configDocuments[0], + config_blob: { + level1: { + name: 'updatedObject', + level2: { + description: 'New description here', + }, + bar: [1, 5], + }, + }, + }, + { + ...configDocuments[1], + config_blob: { + levelA: { + flag: false, + levelB: { + items: ['item1', 'item2', 'item4'], + }, + }, + }, + }, + ], + }, + { + configsToCreate: [], + existingConfigs: configDocuments, + configsToUpdate: [ + { + ...configDocuments[0], + config_blob: { + level1: { + name: 'updatedObject', + level2: { + description: 'New description here', + }, + bar: [1, 5], + }, + }, + }, + { + ...configDocuments[1], + config_blob: { + levelA: { + flag: false, + levelB: { + items: ['item1', 'item2', 'item4'], + }, + }, + }, + }, + { + ...configDocuments[2], + config_blob: { + section: { + id: 'sec1', + levelX: { + title: 'Section Title', + levelY: { + status: 'active', + }, + }, + }, + otherSection: { + values: [14, 11, 2], + }, + }, + }, + ], + }, + ])( + 'should call bulk() with correct operations', + async ({ configsToCreate, configsToUpdate, existingConfigs }) => { + configsToCreate.forEach((config) => { + spyFunction.mockImplementationOnce(() => JSON.stringify(config)); + }); + const configMap = new Map(); + const expectedBulkRequest: Array< + ConfigDocument | { doc: Pick } | BulkOperationContainer + > = []; + const bulkCreateConfigsRequest: ConfigBlob[] = []; + + configsToUpdate.forEach((config) => { + const oldConfig = existingConfigs.filter( + (existingConfig) => existingConfig.config_name === config.config_name + )[0]; + configMap.set(config.config_name, config.config_blob); + expectedBulkRequest.push( + { + update: { + _id: JSON.stringify(oldConfig), + }, + }, + { + doc: { + config_blob: config.config_blob, + }, + } + ); + bulkCreateConfigsRequest.push({ + name: config.config_name, + updatedConfig: config.config_blob, + }); + }); + + configsToCreate.forEach((config) => { + configMap.set(config.config_name, config.config_blob); + expectedBulkRequest.push( + { + create: { + _id: JSON.stringify(config), + }, + }, + { + ...config, + } + ); + bulkCreateConfigsRequest.push({ + name: config.config_name, + updatedConfig: config.config_blob, + }); + }); + + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: existingConfigs, + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.bulkCreateConfigs({ + configs: bulkCreateConfigsRequest, + }); + + expect(mockClient.bulk).toBeCalledWith({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: expectedBulkRequest, + }); + + // Should cache result (search() is always called before bulk() to find existing configs) + const result = await configStoreClient.bulkGetConfigs([...configMap.keys()]); + assertMapsAreEqual(result, configMap); + expect(mockClient.search).toBeCalledTimes(1); + } + ); + }); + + describe('deleteConfig()', () => { + it('should call deleteByQuery() with correct arguments', async () => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: [], + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.deleteConfig({ name: 'some_config_name' }); + + expect(mockClient.deleteByQuery).toBeCalledWith({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + query: { + bool: { + should: [ + { + terms: { + config_name: ['some_config_name'], + }, + }, + ], + }, + }, + }, + }); + }); + }); + + describe('bulkDeleteConfigs()', () => { + it.each([ + { + namespaces: [], + }, + { + namespaces: ['foo'], + }, + { + namespaces: ['foo', 'bar'], + }, + ])('should call deleteByQuery() with $namespaces.length namespaces', async ({ namespaces }) => { + const mockClient = createOpenSearchClientMock({ + isListConfig: false, + configDocuments: [], + existsAliasResult: false, + }); + const configStoreClient = new OpenSearchConfigStoreClient(mockClient); + await configStoreClient.bulkDeleteConfigs({ + paths: namespaces.map((name) => ({ name })), + }); + + expect(mockClient.deleteByQuery).toBeCalledWith({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + query: { + bool: { + should: [ + { + terms: { + config_name: namespaces, + }, + }, + ], + }, + }, + }, + }); + }); + }); +}); diff --git a/src/core/server/config/service/config_store_client/opensearch_config_store_client.ts b/src/core/server/config/service/config_store_client/opensearch_config_store_client.ts new file mode 100644 index 000000000000..a4eef916fcf1 --- /dev/null +++ b/src/core/server/config/service/config_store_client/opensearch_config_store_client.ts @@ -0,0 +1,335 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BulkOperationContainer, SearchHit } from '@opensearch-project/opensearch/api/types'; +import { + DynamicConfigurationClientOptions, + IDynamicConfigStoreClient, +} from 'opensearch-dashboards/server'; +import { uniqueId } from 'lodash'; +import { OpenSearchClient } from '../../../opensearch'; +import { + BulkCreateConfigProps, + BulkDeleteConfigProps, + ConfigBlob, + ConfigObject, + CreateConfigProps, + DeleteConfigProps, +} from '../../types'; +import { + DYNAMIC_APP_CONFIG_ALIAS, + DYNAMIC_APP_CONFIG_MAX_RESULT_SIZE, +} from '../../utils/constants'; +import { pathToString, getDynamicConfigIndexName } from '../../utils/utils'; +import { ConfigDocument } from './types'; + +interface ConfigMapEntry { + configBlob: ConfigBlob; +} + +/** + * This is the default client DAO when "dynamic_config_service.enabled: true" and no plugin has registered a DAO factory. + * This client will fetch configs from .opensearch_dashboards_config alias. + * The alias is important as it will always point to the latest "version" of the config index + */ +export class OpenSearchConfigStoreClient implements IDynamicConfigStoreClient { + readonly #openSearchClient: OpenSearchClient; + readonly #cache: Map = new Map(); + + constructor(openSearchClient: OpenSearchClient) { + this.#openSearchClient = openSearchClient; + } + + /** + * Inserts the config index and an alias that points to it + * + * TODO Add migration logic + */ + public async createDynamicConfigIndex() { + const existsResponse = await this.#openSearchClient.indices.existsAlias({ + name: DYNAMIC_APP_CONFIG_ALIAS, + }); + if (!existsResponse.body) { + await this.#openSearchClient.indices.create({ + index: getDynamicConfigIndexName(1), + }); + await this.#openSearchClient.indices.updateAliases({ + body: { + actions: [ + { + add: { + index: getDynamicConfigIndexName(1), + alias: DYNAMIC_APP_CONFIG_ALIAS, + }, + }, + ], + }, + }); + } + } + + public async getConfig(namespace: string, options?: DynamicConfigurationClientOptions) { + if (this.#cache.has(namespace)) { + return this.#cache.get(namespace); + } + + const result = (await this.searchConfigsRequest([namespace])).body.hits.hits; + + if (result.length <= 0) { + this.#cache.set(namespace, undefined); + return undefined; + } + + const source = result[0]._source; + this.setCacheFromSearch(result[0]); + + return source?.config_blob; + } + + public async bulkGetConfigs(namespaces: string[], options?: DynamicConfigurationClientOptions) { + const results = new Map(); + const configsToQuery = namespaces.filter((namespace) => { + const isCached = this.#cache.has(namespace); + const config = this.#cache.get(namespace); + if (config) { + results.set(namespace, config); + } + return !isCached; + }); + + if (configsToQuery.length <= 0) { + return results; + } + + let nonExistentConfigs = [...configsToQuery]; + const configs = await this.searchConfigsRequest(configsToQuery); + configs.body.hits.hits.forEach((config) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { config_name, config_blob } = config._source!; + nonExistentConfigs = nonExistentConfigs.filter((name) => name !== config_name); + if (config_blob) { + results.set(config_name, config_blob); + } + + this.setCacheFromSearch(config); + }); + + // Cache results that weren't found + nonExistentConfigs.forEach((name) => { + this.#cache.set(name, undefined); + }); + + return results; + } + + public async listConfigs(options?: DynamicConfigurationClientOptions) { + // Cannot get from cache since config keys can be missing + const configs = await this.#openSearchClient.search({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + size: DYNAMIC_APP_CONFIG_MAX_RESULT_SIZE, + query: { + match_all: {}, + }, + }, + }); + + const results = new Map( + configs.body.hits.hits + .filter((config) => { + this.setCacheFromSearch(config); + return !!config._source?.config_blob; + }) + .map((config) => { + return [config._source?.config_name!, config._source?.config_blob!]; + }) + ); + + return results; + } + + public async createConfig( + createConfigProps: CreateConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const { config } = createConfigProps; + const name = pathToString(config); + return await this.createConfigsRequest(new Map([[name, { configBlob: config }]])); + } + + public async bulkCreateConfigs( + bulkCreateConfigProps: BulkCreateConfigProps, + options?: DynamicConfigurationClientOptions + ) { + return await this.createConfigsRequest( + new Map( + bulkCreateConfigProps.configs.map((configBlob) => { + const name = pathToString(configBlob); + return [name, { configBlob }]; + }) + ) + ); + } + + public async deleteConfig( + deleteConfigs: DeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const name = pathToString(deleteConfigs); + return await this.deleteConfigsRequest([name]); + } + + public async bulkDeleteConfigs( + bulkDeleteConfigs: BulkDeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const namespaces = bulkDeleteConfigs.paths.map((path) => { + return pathToString(path); + }); + return await this.deleteConfigsRequest(namespaces); + } + + public clearCache() { + this.#cache.clear(); + } + + /** + * Adds config names to the cache from search hits + * + * @param config + */ + private setCacheFromSearch(config: SearchHit) { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { config_blob, config_name } = config._source!; + this.#cache.set(config_name, config_blob); + } + + /** + * Adds config names to the cache from a config document + * + * @param config + */ + private setCache(config: ConfigDocument) { + this.#cache.set(config.config_name, config.config_blob); + } + + /** + * Sends a bulk update/request to create/update the new configs + * + * @param configMap config name and config blob key/pair values + */ + private async createConfigsRequest(configMap: Map) { + const existingConfigs = await this.searchConfigsRequest([...configMap.keys()], true); + const existingConfigNames: string[] = []; + + // Update the existing configs with the new config blob + const bulkConfigs: Array< + ConfigDocument | BulkOperationContainer + > = existingConfigs.body.hits.hits.flatMap((config) => { + const configName = config._source?.config_name!; + existingConfigNames.push(configName); + const configBlob = configMap.get(configName)?.configBlob.updatedConfig; + this.setCache({ + ...config._source!, + config_blob: configBlob!, + }); + + return [ + { + update: { + _id: config._id, + }, + }, + { + doc: { + // Only need to update the blob + config_blob: configBlob, + }, + }, + ]; + }); + + // Create the rest + const configsToCreate = [...configMap.keys()].filter( + (name) => !existingConfigNames.includes(name) + ); + configsToCreate.forEach((name) => { + const { configBlob } = configMap.get(name)!; + const newConfigDocument = { + config_name: name, + config_blob: configBlob.updatedConfig, + }; + this.setCache(newConfigDocument); + + bulkConfigs.push( + { + create: { + _id: uniqueId(), + }, + }, + newConfigDocument + ); + }); + + return await this.#openSearchClient.bulk({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: bulkConfigs, + }); + } + + /** + * Deletes documents whose config name matches the query + * + * @param namespaces list of config names to search + * @returns + */ + private async deleteConfigsRequest(namespaces: string[]) { + namespaces.forEach((name) => this.#cache.delete(name)); + + return await this.#openSearchClient.deleteByQuery({ + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + query: { + bool: { + should: [ + { + terms: { + config_name: namespaces, + }, + }, + ], + }, + }, + }, + }); + } + + /** + * Returns documents whose config name matches the query + * + * @param namespaces list of config names to search + * @param excludeConfigBlob whether to include the config blob in the response + * @returns + */ + private async searchConfigsRequest(namespaces: string[], excludeConfigBlob: boolean = false) { + return await this.#openSearchClient.search({ + ...(excludeConfigBlob && { _source: ['config_name'] }), + index: DYNAMIC_APP_CONFIG_ALIAS, + body: { + query: { + bool: { + should: [ + { + terms: { + config_name: namespaces, + }, + }, + ], + }, + }, + }, + }); + } +} diff --git a/src/core/server/config/service/config_store_client/opensearch_config_store_factory.ts b/src/core/server/config/service/config_store_client/opensearch_config_store_factory.ts new file mode 100644 index 000000000000..3fac7911925c --- /dev/null +++ b/src/core/server/config/service/config_store_client/opensearch_config_store_factory.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IDynamicConfigStoreClientFactory, + OpenSearchServiceStart, +} from 'opensearch-dashboards/server'; +import { OpenSearchClient } from '../../../opensearch'; +import { OpenSearchConfigStoreClient } from './opensearch_config_store_client'; + +export class OpenSearchDynamicConfigStoreFactory implements IDynamicConfigStoreClientFactory { + readonly #opensearchClient: OpenSearchClient; + + constructor(opensearch: OpenSearchServiceStart) { + this.#opensearchClient = opensearch.client.asInternalUser; + } + + /** + * TODO Once the OpenSearch client is implemented, finish implementing factory method + */ + public create() { + return new OpenSearchConfigStoreClient(this.#opensearchClient); + } +} diff --git a/src/core/server/config/service/config_store_client/types.ts b/src/core/server/config/service/config_store_client/types.ts new file mode 100644 index 000000000000..3a20f828c260 --- /dev/null +++ b/src/core/server/config/service/config_store_client/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ConfigDocument { + config_name: string; + config_blob: Record; +} diff --git a/src/core/server/config/service/configuration_client.mock.ts b/src/core/server/config/service/configuration_client.mock.ts new file mode 100644 index 000000000000..0a5aeede1d9f --- /dev/null +++ b/src/core/server/config/service/configuration_client.mock.ts @@ -0,0 +1,99 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApiResponse } from '@opensearch-project/opensearch/.'; +import { IDynamicConfigurationClient, IInternalDynamicConfigurationClient } from '../types'; + +const createApiResponse = >( + opts: Partial = {} +): ApiResponse => { + return { + body: {} as any, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + ...opts, + }; +}; + +const createInternalDynamicConfigurationClientMock = ( + props: InternalDynamicConfigurationClientMockProps = { + getConfig: {}, + bulkGetConfigs: new Map>(), + listConfigs: new Map>(), + } +) => { + const mocked: jest.Mocked = { + getConfig: jest.fn(), + bulkGetConfigs: jest.fn(), + listConfigs: jest.fn(), + createConfig: jest.fn(), + bulkCreateConfigs: jest.fn(), + deleteConfig: jest.fn(), + bulkDeleteConfigs: jest.fn(), + }; + mocked.getConfig.mockResolvedValue(props.getConfig); + mocked.bulkGetConfigs.mockResolvedValue(props.bulkGetConfigs); + mocked.listConfigs.mockResolvedValue(props.listConfigs); + mocked.createConfig.mockResolvedValue(createApiResponse(props.createConfig)); + mocked.bulkCreateConfigs.mockResolvedValue(createApiResponse(props.bulkCreateConfigs)); + mocked.deleteConfig.mockResolvedValue(createApiResponse(props.deleteConfig)); + mocked.bulkDeleteConfigs.mockResolvedValue(createApiResponse(props.bulkDeleteConfigs)); + return mocked; +}; + +const createDynamicConfigurationClientMock = ( + props: DynamicConfigurationClientMockProps = { + getConfig: {}, + bulkGetConfigs: new Map>(), + listConfigs: new Map>(), + } +) => { + const mocked: jest.Mocked = { + getConfig: jest.fn(), + bulkGetConfigs: jest.fn(), + listConfigs: jest.fn(), + }; + mocked.getConfig.mockImplementation((getConfigProps, options) => { + if (getConfigProps.name && getConfigProps.name === 'csp') { + return Promise.resolve({ + rules: [], + strict: false, + warnLegacyBrowsers: false, + }); + } + + return Promise.resolve(props.getConfig); + }); + mocked.bulkGetConfigs.mockResolvedValue(props.bulkGetConfigs); + mocked.listConfigs.mockResolvedValue(props.listConfigs); + return mocked; +}; + +export interface InternalDynamicConfigurationClientMockProps { + getConfig: Record; + bulkGetConfigs: Map>; + listConfigs: Map>; + createConfig?: Partial; + bulkCreateConfigs?: Partial; + deleteConfig?: Partial; + bulkDeleteConfigs?: Partial; +} + +export type DynamicConfigurationClientMockProps = Pick< + InternalDynamicConfigurationClientMockProps, + 'getConfig' | 'bulkGetConfigs' | 'listConfigs' +>; + +export const internalDynamicConfigurationClientMock = { + create: createInternalDynamicConfigurationClientMock, + createApiResponse, +}; + +export const dynamicConfigurationClientMock = { + create: createDynamicConfigurationClientMock, + createApiResponse, +}; diff --git a/src/core/server/config/service/dynamic_configuration_client.ts b/src/core/server/config/service/dynamic_configuration_client.ts new file mode 100644 index 000000000000..0cfd6a8c8b27 --- /dev/null +++ b/src/core/server/config/service/dynamic_configuration_client.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InternalDynamicConfigurationClient } from './internal_dynamic_configuration_client'; +import { + BulkGetConfigProps, + DynamicConfigurationClientOptions, + GetConfigProps, + IDynamicConfigurationClient, +} from '../types'; + +export class DynamicConfigurationClient implements IDynamicConfigurationClient { + readonly #dynamicConfigurationClient: InternalDynamicConfigurationClient; + + constructor(internalDynamicConfigurationClient: InternalDynamicConfigurationClient) { + this.#dynamicConfigurationClient = internalDynamicConfigurationClient; + } + + public async getConfig(props: GetConfigProps, options?: DynamicConfigurationClientOptions) { + return this.#dynamicConfigurationClient.getConfig(props, options); + } + + public async bulkGetConfigs( + props: BulkGetConfigProps, + options?: DynamicConfigurationClientOptions + ) { + return this.#dynamicConfigurationClient.bulkGetConfigs(props, options); + } + + public async listConfigs(options?: DynamicConfigurationClientOptions) { + return this.#dynamicConfigurationClient.listConfigs(options); + } +} diff --git a/src/core/server/config/service/internal_dynamic_configuration_client.test.ts b/src/core/server/config/service/internal_dynamic_configuration_client.test.ts new file mode 100644 index 000000000000..d772c77fca74 --- /dev/null +++ b/src/core/server/config/service/internal_dynamic_configuration_client.test.ts @@ -0,0 +1,744 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { of } from 'rxjs'; +import { configMock, configServiceMock } from '../mocks'; +import { InternalDynamicConfigurationClient } from './internal_dynamic_configuration_client'; +import { loggerMock } from '../../logging/logger.mock'; +import { Env } from '@osd/config'; +import _ from 'lodash'; +import { schema, Type } from '@osd/config-schema'; +import { IDynamicConfigStoreClient } from 'opensearch-dashboards/server'; +import { ApiResponse } from '@opensearch-project/opensearch/.'; + +describe('InternalDynamicConfigStoreClient', () => { + const existingConfigServiceMock = configServiceMock.create(); + + interface CreateConfigStoreClientMockProps { + getConfigReturnValue?: Record; + bulkGetConfigsReturnValue?: Map>; + listConfigsReturnValue?: Map>; + createConfigReturnValue?: ApiResponse; + bulkCreateConfigsReturnValue?: ApiResponse; + deleteConfigReturnValue?: ApiResponse; + bulkDeleteConfigsReturnValue?: ApiResponse; + } + + const createConfigStoreClientMock = ({ + getConfigReturnValue, + bulkGetConfigsReturnValue, + listConfigsReturnValue, + createConfigReturnValue, + bulkCreateConfigsReturnValue, + deleteConfigReturnValue, + bulkDeleteConfigsReturnValue, + }: CreateConfigStoreClientMockProps): IDynamicConfigStoreClient => { + return { + getConfig: jest.fn().mockResolvedValue(getConfigReturnValue), + bulkGetConfigs: jest.fn().mockResolvedValue(bulkGetConfigsReturnValue), + listConfigs: jest.fn().mockResolvedValue(listConfigsReturnValue), + createConfig: jest.fn().mockResolvedValue(createConfigReturnValue), + bulkCreateConfigs: jest.fn().mockResolvedValue(bulkCreateConfigsReturnValue), + deleteConfig: jest.fn().mockResolvedValue(deleteConfigReturnValue), + bulkDeleteConfigs: jest.fn().mockResolvedValue(bulkDeleteConfigsReturnValue), + }; + }; + + beforeEach(() => { + existingConfigServiceMock.atPath = jest.fn().mockImplementation((namespace) => {}); + existingConfigServiceMock.getConfig$ = jest.fn().mockReturnValue({}); + }); + + describe('getConfig()', () => { + interface GetConfigTestCaseFormat { + configName: string; + oldConfig: Record; + expectedConfig: Record; + dynamicConfigStoreMockResponse: Record; + schemaStructure: Type; + } + + it('should throw an error for a non-existent config name', async () => { + const dynamicConfigStoreClient = createConfigStoreClientMock({ getConfigReturnValue: {} }); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient, + configService: configServiceMock.create(), + env: {} as Env, + logger: loggerMock.create(), + schemas: new Map(), + }); + + const name = 'non_existent_name'; + await expect(internalClient.getConfig({ name })).rejects.toThrow( + `schema for ${name} not found` + ); + }); + + it.each([ + { + configName: 'somePlugin', + oldConfig: { + a: 'foo', + b: 'old', + c: { + d: 2, + }, + }, + expectedConfig: { + a: 'foo', + b: 'bar', + c: { + d: 4, + }, + }, + dynamicConfigStoreMockResponse: { + b: 'bar', + c: { + d: 4, + }, + }, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.string(), + c: schema.object({ + d: schema.number(), + }), + }), + }, + { + configName: 'somePluginWithNoCustomerConfigs', + oldConfig: { + a: 'foo', + b: { + c: { + d: true, + }, + }, + }, + expectedConfig: { + a: 'foo', + b: { + c: { + d: true, + }, + }, + }, + dynamicConfigStoreMockResponse: {}, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.object({ + c: schema.object({ + d: schema.boolean(), + }), + }), + }), + }, + ])( + '$configName should merge configs from DDB and old config service', + async ({ + configName, + oldConfig, + expectedConfig, + dynamicConfigStoreMockResponse, + schemaStructure, + }) => { + const schemas = new Map([[_.snakeCase(configName), schemaStructure]]); + + const dynamicConfigStoreClient = createConfigStoreClientMock({ + getConfigReturnValue: dynamicConfigStoreMockResponse, + }); + + const mockedObservable = of(oldConfig); + const configService = configServiceMock.create(); + configService.atPath = jest.fn().mockReturnValue(mockedObservable); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient as any, + configService, + env: {} as Env, + logger: loggerMock.create(), + schemas, + }); + + const actualConfigResponse = await internalClient.getConfig({ name: configName }); + + expect(_.isEqual(actualConfigResponse, expectedConfig)).toBe(true); + } + ); + }); + + describe('bulkGetConfigs()', () => { + interface ConfigListItem { + configName: string; + oldConfig: Record; + dynamicConfigStoreMockResponse: Record; + schemaStructure: Type; + } + interface BulkGetConfigTestCaseFormat { + configsList: ConfigListItem[]; + expectedConfigs: Map>; + } + + interface BulkGetConfigNonExistentTestCaseFormat { + configsList: ConfigListItem[]; + nonExistentConfigs: string[]; + } + + it.each([ + { + configsList: [], + nonExistentConfigs: ['foo'], + }, + { + configsList: [], + nonExistentConfigs: ['foo', 'bar'], + }, + { + configsList: [ + { + configName: 'foo', + oldConfig: { + a: 13, + b: { + c: true, + }, + }, + dynamicConfigStoreMockResponse: { + b: { + c: false, + }, + }, + schemaStructure: schema.object({ + a: schema.number(), + b: schema.object({ + c: schema.boolean(), + }), + }), + }, + ], + nonExistentConfigs: ['foo', 'bar'], + }, + ])( + 'should fail when the configs $nonExistentConfigs are attempted to be called', + async ({ configsList, nonExistentConfigs }) => { + const schemas = new Map(); + const dynamicConfigStoreMockResponses = new Map(); + const mockedObservableMap = new Map(); + + configsList.forEach((configItem) => { + const processedConfigName = _.snakeCase(configItem.configName); + schemas.set(processedConfigName, configItem.schemaStructure); + dynamicConfigStoreMockResponses.set( + processedConfigName, + configItem.dynamicConfigStoreMockResponse + ); + mockedObservableMap.set(processedConfigName, of(configItem.oldConfig)); + }); + + const dynamicConfigStoreClient = createConfigStoreClientMock({ + bulkGetConfigsReturnValue: dynamicConfigStoreMockResponses, + }); + + const configService = configServiceMock.create(); + configService.atPath = jest.fn().mockImplementation((namespace: string) => { + if (!mockedObservableMap.has(namespace)) { + throw new Error(`${namespace} is not defined/found in the mockedObservableMap`); + } + return mockedObservableMap.get(namespace); + }); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient as any, + configService, + env: {} as Env, + logger: loggerMock.create(), + schemas, + }); + + const paths = nonExistentConfigs.map((configName: string) => { + return { name: configName }; + }); + + await expect(internalClient.bulkGetConfigs({ paths })).rejects.toThrow(/schema for/); + } + ); + + it.each([ + { + configsList: [ + { + configName: 'foo', + oldConfig: { + a: 'value', + b: true, + c: { + d: { + e: 1, + }, + }, + }, + dynamicConfigStoreMockResponse: { + c: { + d: { + e: 536, + }, + }, + }, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.boolean(), + c: schema.object({ + d: schema.object({ + e: schema.number(), + }), + }), + }), + }, + ], + expectedConfigs: new Map([ + [ + 'foo', + { + a: 'value', + b: true, + c: { + d: { + e: 536, + }, + }, + }, + ], + ]), + }, + { + configsList: [ + { + configName: 'foo', + oldConfig: { + a: 'value', + b: true, + c: { + d: { + e: 1, + }, + }, + }, + dynamicConfigStoreMockResponse: { + c: { + d: { + e: 536, + }, + }, + }, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.boolean(), + c: schema.object({ + d: schema.object({ + e: schema.number(), + }), + }), + }), + }, + { + configName: 'bar', + oldConfig: { + a: '13', + b: 13, + c: { + d: true, + }, + }, + dynamicConfigStoreMockResponse: { + a: '24561', + }, + schemaStructure: schema.object({ + a: schema.string(), + b: schema.number(), + c: schema.object({ + d: schema.boolean(), + }), + }), + }, + { + configName: 'baz', + oldConfig: { + a: { + b: true, + }, + c: 'someString', + }, + dynamicConfigStoreMockResponse: {}, + schemaStructure: schema.object({ + a: schema.object({ + b: schema.boolean(), + }), + c: schema.string(), + }), + }, + ], + expectedConfigs: new Map([ + [ + 'foo', + { + a: 'value', + b: true, + c: { + d: { + e: 536, + }, + }, + }, + ], + [ + 'bar', + { + a: '24561', + b: 13, + c: { + d: true, + }, + }, + ], + [ + 'baz', + { + a: { + b: true, + }, + c: 'someString', + }, + ], + ]), + }, + ])( + 'should merge configs for one or many requested configs', + async ({ configsList, expectedConfigs }) => { + const schemas = new Map(); + const dynamicConfigStoreMockResponses = new Map(); + const mockedObservableMap = new Map(); + + configsList.forEach((configItem) => { + const processedConfigName = _.snakeCase(configItem.configName); + schemas.set(processedConfigName, configItem.schemaStructure); + dynamicConfigStoreMockResponses.set( + processedConfigName, + configItem.dynamicConfigStoreMockResponse + ); + mockedObservableMap.set(processedConfigName, of(configItem.oldConfig)); + }); + + const dynamicConfigStoreClient = createConfigStoreClientMock({ + bulkGetConfigsReturnValue: dynamicConfigStoreMockResponses, + }); + + const configService = configServiceMock.create(); + configService.atPath = jest.fn().mockImplementation((namespace: string) => { + if (!mockedObservableMap.has(namespace)) { + throw new Error(`${namespace} is not defined/found in the mockedObservableMap`); + } + return mockedObservableMap.get(namespace); + }); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient as any, + configService, + env: {} as Env, + logger: loggerMock.create(), + schemas, + }); + + const paths = configsList.map((configItem) => { + return { name: configItem.configName }; + }); + + const actualResponse = await internalClient.bulkGetConfigs({ paths }); + + expect(actualResponse.size).toBe(expectedConfigs.size); + for (const [key, value] of expectedConfigs) { + expect(actualResponse.has(key)).toBe(true); + expect(_.isEqual(actualResponse.get(key), value)).toBe(true); + } + } + ); + }); + + describe('listConfigs()', () => { + interface ListConfigsTestCaseFormat { + oldConfigs: Map>; + dynamicConfigStoreMockResponse: Map>; + expectedConfigs: Map>; + } + + it.each([ + { + oldConfigs: new Map([ + [ + 'foo', + { + a: true, + b: 1, + c: { + d: '143', + }, + }, + ], + ]), + dynamicConfigStoreMockResponse: new Map(), + expectedConfigs: new Map(), + }, + { + oldConfigs: new Map([ + [ + 'foo', + { + key1: 'value1', + key2: { + subKey1: 123, + subKey2: true, + subKey3: ['a', 'b', 'c'], + }, + key3: { + subKey4: '', + subKey5: { + subSubKey1: false, + subSubKey2: 0, + }, + }, + }, + ], + [ + 'bar', + { + key1: 'value1', + key2: { + subKey1: 42, + subKey2: true, + subKey3: ['x', 'y', 'z'], + }, + key3: { + subKey4: 'abc', + subKey5: { + subSubKey1: false, + subSubKey2: -10, + }, + }, + }, + ], + ]), + dynamicConfigStoreMockResponse: new Map([ + [ + 'foo', + { + key1: 'value3', + key2: { + subKey2: false, + subKey3: ['b', 'c'], + }, + key3: { + subKey5: { + subSubKey1: true, + }, + }, + }, + ], + ]), + expectedConfigs: new Map([ + [ + 'foo', + { + key1: 'value3', + key2: { + subKey2: false, + subKey3: ['b', 'c'], + }, + key3: { + subKey5: { + subSubKey1: true, + }, + }, + }, + ], + ]), + }, + { + oldConfigs: new Map([ + [ + 'foo', + { + a: 'John', + b: 30, + c: true, + d: ['reading', 'painting'], + e: { + f: 'New York', + g: 10001, + h: { + i: 40.7128, + j: -74.006, + }, + }, + }, + ], + [ + 'bar', + { + key1: 'value1', + key2: { + subKey1: 3.14, + subKey2: true, + subKey3: ['a', 'b', 'c'], + }, + key3: { + subKey4: 'hello', + subKey5: { + subSubKey1: false, + subSubKey2: 0, + }, + }, + }, + ], + [ + 'baz', + { + a: { + a1: 'Smartphone', + a2: 'ABC', + a3: 599.99, + a4: true, + a5: { + aa1: { + aaa1: '6.5 inches', + aaa2: 'AMOLED', + }, + aa2: { + aab1: '12 MP', + aab2: ['HDR', 'Night Mode'], + }, + }, + }, + b: { + b1: 'XYZ Electronics', + b2: 4.7, + b3: { + bb1: 'Los Angeles', + bb2: 'California', + }, + }, + }, + ], + ]), + dynamicConfigStoreMockResponse: new Map([ + [ + 'foo', + { + c: true, + d: ['reading', 'running', 'drawing'], + e: { + h: { + j: -13.01, + }, + }, + }, + ], + [ + 'baz', + { + a: { + a5: { + aa1: { + aaa2: 'LED', + }, + }, + }, + b: { + b1: 'ABC Corp', + b3: { + bb1: 'San Francisco', + bb2: 'California', + }, + }, + }, + ], + ]), + expectedConfigs: new Map([ + [ + 'foo', + { + c: true, + d: ['reading', 'running', 'drawing'], + e: { + h: { + j: -13.01, + }, + }, + }, + ], + [ + 'baz', + { + a: { + a5: { + aa1: { + aaa2: 'LED', + }, + }, + }, + b: { + b1: 'ABC Corp', + b3: { + bb1: 'San Francisco', + bb2: 'California', + }, + }, + }, + ], + ]), + }, + ])( + 'should merge configs when 0, 1, or multiple customer configs are present', + async ({ oldConfigs, dynamicConfigStoreMockResponse, expectedConfigs }) => { + const configStoreMock = configMock.create(); + configStoreMock.get = jest.fn().mockImplementation((configName: string) => { + if (!oldConfigs.has(configName)) { + throw new Error(`${configName} not found/defined in oldConfigs`); + } + + return oldConfigs.get(configName); + }); + const configService = configServiceMock.create(); + configService.getConfig$ = jest.fn().mockReturnValue(configStoreMock); + + const schemas = new Map(); + oldConfigs.forEach((value, key) => { + const processedConfigName = _.snakeCase(key); + // Since schemas values will not be used, the value is stubbed + schemas.set(processedConfigName, {}); + }); + + const dynamicConfigStoreClient = createConfigStoreClientMock({ + listConfigsReturnValue: dynamicConfigStoreMockResponse, + }); + + const internalClient = new InternalDynamicConfigurationClient({ + client: dynamicConfigStoreClient as any, + configService, + env: {} as Env, + logger: loggerMock.create(), + schemas, + }); + + const actualResponse = await internalClient.listConfigs(); + + expect(actualResponse.size).toBe(expectedConfigs.size); + for (const [key, value] of expectedConfigs) { + expect(actualResponse.has(key)).toBe(true); + expect(_.isEqual(actualResponse.get(key), value)).toBe(true); + } + } + ); + }); +}); diff --git a/src/core/server/config/service/internal_dynamic_configuration_client.ts b/src/core/server/config/service/internal_dynamic_configuration_client.ts new file mode 100644 index 000000000000..2e60ac78ed33 --- /dev/null +++ b/src/core/server/config/service/internal_dynamic_configuration_client.ts @@ -0,0 +1,169 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Type, TypeOf } from '@osd/config-schema'; +import { Env, IConfigService } from '@osd/config'; +import { first } from 'rxjs/operators'; +import { Logger } from '@osd/logging'; +import { + BulkCreateConfigProps, + BulkDeleteConfigProps, + BulkGetConfigProps, + CreateConfigProps, + DeleteConfigProps, + IInternalDynamicConfigurationClient, + DynamicConfigurationClientOptions, + GetConfigProps, + IDynamicConfigStoreClient, +} from '../types'; +import { mergeConfigs, pathToString } from '../utils/utils'; + +export interface InternalDynamicConfigurationClientProps { + client: IDynamicConfigStoreClient; + logger: Logger; + schemas: Map>; + env: Env; + configService: IConfigService; +} + +export class InternalDynamicConfigurationClient implements IInternalDynamicConfigurationClient { + readonly #client: IDynamicConfigStoreClient; + readonly #logger: Logger; + readonly #schemas: Map>; + readonly #configService: IConfigService; + + constructor(props: InternalDynamicConfigurationClientProps) { + const { client, logger, schemas, configService } = props; + this.#client = client; + this.#schemas = schemas; + this.#configService = configService; + this.#logger = logger; + } + + public async getConfig( + getConfigProps: GetConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const namespace = pathToString(getConfigProps); + const defaultConfig = await this.getDefaultConfig(namespace); + + // If this call fails/returns undefined, default to the defaultConfig + const configStoreConfig = await this.#client.getConfig(namespace, options); + + return configStoreConfig ? mergeConfigs(defaultConfig, configStoreConfig) : defaultConfig; + } + + public async bulkGetConfigs( + bulkGetConfig: BulkGetConfigProps, + options?: DynamicConfigurationClientOptions + ) { + const namespaces = bulkGetConfig.paths.map((path) => pathToString(path)); + const defaultConfigsMap = new Map>(); + + // TODO Determine whether to pass through or completely fail a bulkGet() call if a namespace does not exist + for (const namespace of namespaces) { + const config = await this.getDefaultConfig(namespace); + defaultConfigsMap.set(namespace, config); + } + + // If this call fails/returns undefined, default to the defaultConfig + const configStoreConfig = await this.#client.bulkGetConfigs(namespaces, options); + if (!configStoreConfig.size) { + return defaultConfigsMap; + } + + const finalConfigsMap = new Map>([...defaultConfigsMap]); + configStoreConfig.forEach((newConfig, configName) => { + const oldConfig = defaultConfigsMap.get(configName); + + if (!oldConfig) { + this.#logger.warn(`Config ${configName} not found`); + return defaultConfigsMap; + } + + const finalConfig = mergeConfigs(oldConfig!, newConfig); + finalConfigsMap.set(configName, finalConfig); + }); + + return finalConfigsMap; + } + + // TODO Determine if the listConfigs() should only list the configs for the config store or ALL configs + public async listConfigs(options?: DynamicConfigurationClientOptions) { + return await this.#client.listConfigs(options); + } + + public async createConfig( + createConfigProps: CreateConfigProps, + options?: DynamicConfigurationClientOptions + ) { + // TODO Add validation logic + return await this.#client.createConfig(createConfigProps, options); + } + + public async bulkCreateConfigs( + bulkCreateConfigProps: BulkCreateConfigProps, + options?: DynamicConfigurationClientOptions + ) { + // TODO Add validation logic + return await this.#client.bulkCreateConfigs(bulkCreateConfigProps, options); + } + + public async deleteConfig( + deleteConfigs: DeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) { + return await this.#client.deleteConfig(deleteConfigs, options); + } + + public async bulkDeleteConfigs( + bulkDeleteConfigProps: BulkDeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) { + return await this.#client.bulkDeleteConfigs(bulkDeleteConfigProps, options); + } + + /** + * Given the top level config, obtain the top level config from the config store + * + * @param namespace The config name to fetch the registered schema + * @private + */ + private async getDefaultConfig(namespace: string) { + const schema = this.#schemas.get(namespace); + if (!schema) { + throw new Error(`schema for ${namespace} not found`); + } + return (await this.#configService + .atPath>(namespace) + .pipe(first()) + .toPromise()) as Record; + } + + /** + * Returns the entire config as a Map of config names and schema values + * + * @private + * + * TODO This should only be implemented if listConfigs() will show configs not shown in config store + * private async getAllDefaultConfigs(): Promise>> { + * const configStore = await this.configService.getConfig$().toPromise(); + * const configMap = new Map(); + * Array.from(this.schemas.keys()).map((configName) => { + * configMap.set(configName, configStore.get(configName)); + * }); + * return configMap; + * } + */ + + /** + * TODO Implement validateConfig, which given a config blob and top level config name, validates it against the registered schema + * - see {@link ConfigService} validateAtPath() for reference + * + * @param configIdentifier + * @param config + * @private + */ +} diff --git a/src/core/server/config/types.ts b/src/core/server/config/types.ts new file mode 100644 index 000000000000..4b02701195cd --- /dev/null +++ b/src/core/server/config/types.ts @@ -0,0 +1,147 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApiResponse } from '@opensearch-project/opensearch/.'; +import { ConfigPath } from '@osd/config'; +import { OpenSearchDashboardsRequest } from '../http'; + +/** + * Setup allows core services and plugins to register config stores and any context to passed into async local store + * + * @interface + */ +export interface InternalDynamicConfigServiceSetup { + registerDynamicConfigClientFactory: (factory: IDynamicConfigStoreClientFactory) => void; + registerAsyncLocalStoreRequestHeader: (key: string | string[]) => void; + getStartService: () => Promise; +} + +export type DynamicConfigServiceSetup = InternalDynamicConfigServiceSetup; + +export interface InternalDynamicConfigServiceStart { + getClient: () => IDynamicConfigurationClient; + getAsyncLocalStore: () => AsyncLocalStorageContext | undefined; + createStoreFromRequest: ( + request: OpenSearchDashboardsRequest + ) => AsyncLocalStorageContext | undefined; +} + +export type DynamicConfigServiceStart = InternalDynamicConfigServiceStart; + +export type IDynamicConfigurationClient = Pick< + IInternalDynamicConfigurationClient, + 'getConfig' | 'bulkGetConfigs' | 'listConfigs' +>; + +export interface IInternalDynamicConfigurationClient { + createConfig: ( + createConfigProps: CreateConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; + bulkCreateConfigs: ( + bulkCreateConfigProps: BulkCreateConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; + getConfig: ( + getConfigProps: GetConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; + bulkGetConfigs: ( + bulkGetConfigProps: BulkGetConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise>; + listConfigs: (options?: DynamicConfigurationClientOptions) => Promise>; + deleteConfig: ( + deleteConfigs: DeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; + bulkDeleteConfigs: ( + bulkDeleteConfigs: BulkDeleteConfigProps, + options?: DynamicConfigurationClientOptions + ) => Promise; +} + +export interface IDynamicConfigStoreClientFactory { + create: () => IDynamicConfigStoreClient; +} + +/** + * Client used to retrieve dynamic configs from the config store of choice + * + * @interface + */ +export type IDynamicConfigStoreClient = Pick< + IInternalDynamicConfigurationClient, + 'listConfigs' | 'bulkCreateConfigs' | 'createConfig' | 'bulkDeleteConfigs' | 'deleteConfig' +> & { + getConfig: ( + namespace: string, + options?: DynamicConfigurationClientOptions + ) => Promise; + bulkGetConfigs: ( + namespaces: string[], + options?: DynamicConfigurationClientOptions + ) => Promise>; +}; + +export interface DynamicConfigurationClientOptions { + asyncLocalStorageContext: AsyncLocalStorageContext; +} + +export type ConfigObject = Record; + +/** + * Provides the necessary context needed when a request first hits the http server + * + * @interface AsyncLocalStorageContext + */ +export type AsyncLocalStorageContext = Map; + +export type ConfigIdentifier = + | { + /** + * How plugin and core service schemas are identified. + * - For plugins, this is the plugin id (the 'id' field in camelCase that can be found in the plugin manifest) + * - Use pluginConfigPath if a plugin has the 'configPath' property set in its manifest + * - For core services, this is the path property found in the config schema + * - example: {@link HttpConfig} config name is 'server' + */ + name: string; + pluginConfigPath?: never; + } + | { + /** + * For plugins ONLY. This is the optional field 'configPath' in the plugin manifest. If a given plugin has 'configPath' in its manifest, set this value instead of name. + */ + pluginConfigPath: ConfigPath; + name?: never; + }; + +export type ConfigBlob = ConfigIdentifier & { + /** + * The new config blob to override the old config in the config store. This will update the entire config blob for a configIdentifier + */ + updatedConfig: ConfigObject; +}; + +export interface CreateConfigProps { + config: ConfigBlob; +} + +export interface BulkCreateConfigProps { + configs: ConfigBlob[]; +} + +export type GetConfigProps = ConfigIdentifier; + +export interface BulkGetConfigProps { + paths: ConfigIdentifier[]; +} + +export type DeleteConfigProps = ConfigIdentifier; + +export interface BulkDeleteConfigProps { + paths: ConfigIdentifier[]; +} diff --git a/src/core/server/config/utils/constants.ts b/src/core/server/config/utils/constants.ts new file mode 100644 index 000000000000..5401063af385 --- /dev/null +++ b/src/core/server/config/utils/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// TODO Once the migration logic is added, refactor the index name to some function that returns the Nth version +export const DYNAMIC_APP_CONFIG_INDEX_PREFIX = '.opensearch_dashboards_dynamic_config'; +export const DYNAMIC_APP_CONFIG_ALIAS = '.opensearch_dashboards_dynamic_config'; +export const DYNAMIC_APP_CONFIG_MAX_RESULT_SIZE = 1000; diff --git a/src/core/server/config/utils/utils.test.ts b/src/core/server/config/utils/utils.test.ts new file mode 100644 index 000000000000..1f0a3c0a5aa6 --- /dev/null +++ b/src/core/server/config/utils/utils.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createLocalStore, mergeConfigs, pathToString } from './utils'; +import { Request } from 'hapi__hapi'; +import { loggerMock } from '../../logging/logger.mock'; + +describe('Utils', () => { + test.each([ + { + name: '', + pluginConfigPath: undefined, + expected: '', + }, + { + name: 'somePlugin', + pluginConfigPath: undefined, + expected: 'some_plugin', + }, + { + name: undefined, + pluginConfigPath: '', + expected: '', + }, + { + name: undefined, + pluginConfigPath: 'someOtherPlugin', + expected: 'someOtherPlugin', + }, + { + name: undefined, + pluginConfigPath: 'some_other_plugin', + expected: 'some_other_plugin', + }, + { + name: undefined, + pluginConfigPath: ['some'], + expected: 'some', + }, + { + name: undefined, + pluginConfigPath: ['some', 'Path'], + expected: 'some.Path', + }, + { + name: undefined, + pluginConfigPath: ['some', 'path'], + expected: 'some.path', + }, + ])( + 'pathToString() should correctly parse name objects', + ({ name, pluginConfigPath, expected }) => { + const result = name + ? pathToString({ name }) + : pathToString({ pluginConfigPath: pluginConfigPath! }); + expect(result).toBe(expected); + } + ); + + test.each([ + { + updatedConfig: { x: 10, y: 20 }, + oldConfig: { y: 15, z: 25 }, + finalConfig: { x: 10, y: 20, z: 25 }, + }, + { + updatedConfig: { x: 10, y: { z: 20, w: 30 } }, + oldConfig: { x: 5, y: { z: 15, v: 25 }, q: 50 }, + finalConfig: { x: 10, y: { z: 20, v: 25, w: 30 }, q: 50 }, + }, + { + updatedConfig: { x: [1, 2, 3], y: 20 }, + oldConfig: { x: [4, 5], y: 15 }, + finalConfig: { x: [1, 2, 3], y: 20 }, + }, + { + updatedConfig: { x: { y: [1, 2, 3], z: 30 } }, + oldConfig: { x: { y: [4, 5], w: 25 } }, + finalConfig: { x: { y: [1, 2, 3], z: 30, w: 25 } }, + }, + { + updatedConfig: { a: { b: { c: { d: 10 } } } }, + oldConfig: { a: { b: { c: { e: 20 } } } }, + finalConfig: { a: { b: { c: { d: 10, e: 20 } } } }, + }, + // This test case demonstrates that if updated configs have undefined fields, they will not be applied + { + updatedConfig: { a: null, b: undefined, c: 30 }, + oldConfig: { a: 10, b: 20, c: null }, + finalConfig: { a: null, b: 20, c: 30 }, + }, + { + updatedConfig: {}, + oldConfig: { x: 10, y: 20 }, + finalConfig: { x: 10, y: 20 }, + }, + { + updatedConfig: { x: 10, y: 20 }, + oldConfig: {}, + finalConfig: { x: 10, y: 20 }, + }, + ])( + 'mergeConfigs() should override all oldConfigs with newConfigs for specified fields except for undefined fields', + ({ oldConfig, updatedConfig, finalConfig }) => { + expect(mergeConfigs(oldConfig, updatedConfig)).toMatchObject(finalConfig); + } + ); + + test.each([ + { + headers: [], + requestMock: { + headers: { + foo: 'bar', + }, + }, + expectedMap: new Map(), + }, + { + headers: ['some-header'], + requestMock: { + headers: { + 'some-header': 'some-value', + foo: 'bar', + }, + }, + expectedMap: new Map([['some-header', 'some-value']]), + }, + { + headers: ['some-header'], + requestMock: { + headers: { + foo: 'bar', + }, + }, + expectedMap: new Map([['some-header', undefined]]), + }, + { + headers: ['some-header', 'some-other-header'], + requestMock: { + headers: { + foo: 'bar', + 'some-header': 'some-value', + }, + }, + expectedMap: new Map([ + ['some-header', 'some-value'], + ['some-other-header', undefined], + ]), + }, + { + headers: ['some-header', 'some-other-header'], + requestMock: { + headers: { + foo: 'bar', + 'some-header': 'some-value', + 'some-other-header': 'some-other-value', + }, + }, + expectedMap: new Map([ + ['some-header', 'some-value'], + ['some-other-header', 'some-other-value'], + ]), + }, + ])( + 'createLocalStore() should create a local store from the following headers: $headers', + ({ headers, requestMock, expectedMap }) => { + const actualMap = createLocalStore( + loggerMock.create(), + (requestMock as unknown) as Request, + headers + ); + expect(actualMap.size).toEqual(expectedMap.size); + expectedMap.forEach((value, key) => { + expect(actualMap.get(key)).toEqual(value); + }); + } + ); +}); diff --git a/src/core/server/config/utils/utils.ts b/src/core/server/config/utils/utils.ts new file mode 100644 index 000000000000..f7283f18a96e --- /dev/null +++ b/src/core/server/config/utils/utils.ts @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import _ from 'lodash'; +import { Logger } from '@osd/logging'; +import { Request } from 'hapi__hapi'; +import { ConfigIdentifier } from '../types'; +import { DYNAMIC_APP_CONFIG_INDEX_PREFIX } from './constants'; +import { OpenSearchDashboardsRequest } from '../../http'; + +/** + * Given a configIdentifier: + * - if name is provided, convert it from camelCase to snake_case + * - if pluginConfigPath is provided (for plugin configs ONLY), convert the ["config", "path"] to config.path + * + * @param configIdentifier + */ +export const pathToString = (configIdentifier: ConfigIdentifier) => { + const { name, pluginConfigPath } = configIdentifier; + if (pluginConfigPath) { + return Array.isArray(pluginConfigPath) ? pluginConfigPath.join('.') : pluginConfigPath; + } + return _.snakeCase(name); +}; + +/** + * Given the config from the config file and the config store, merge the two configs. + * + * @param defaultConfigs + * @param configStoreConfigs + */ +export const mergeConfigs = ( + defaultConfigs: Record, + configStoreConfigs: Record +) => { + // Ensures that the entire array of the configStoreConfigs overrides existing configs + const mergeCustomizer = (target: any, source: any) => { + if (_.isArray(target)) { + return source; + } + }; + return _.mergeWith(defaultConfigs, configStoreConfigs, mergeCustomizer); +}; + +export const createLocalStore = (logger: Logger, request: Request, headers: string[]) => { + return new Map( + headers.map((header: string) => { + try { + return [header, request.headers[header]]; + } catch (err) { + logger.warn(`Header ${header} not found in request`); + return [header, undefined]; + } + }) + ); +}; + +export const getDynamicConfigIndexName = (n: number) => { + return `${DYNAMIC_APP_CONFIG_INDEX_PREFIX}_${n}`; +}; + +export const createLocalStoreFromOsdRequest = ( + logger: Logger, + request: OpenSearchDashboardsRequest, + headers: string[] +) => { + if (!request.auth.isAuthenticated) { + return undefined; + } + return new Map( + headers.map((header: string) => { + try { + logger.debug(`${header}: ${request.headers[header]}`); + return [header, request.headers[header]]; + } catch (err) { + logger.warn(`Header ${header} not found in request`); + return [header, undefined]; + } + }) + ); +}; diff --git a/src/core/server/core_context.mock.ts b/src/core/server/core_context.mock.ts index b4f08e467cc1..d8e7a3ca5f32 100644 --- a/src/core/server/core_context.mock.ts +++ b/src/core/server/core_context.mock.ts @@ -34,17 +34,21 @@ import { Env, IConfigService } from './config'; import { configServiceMock, getEnvOptions } from './config/mocks'; import { loggingSystemMock } from './logging/logging_system.mock'; import { ILoggingSystem } from './logging'; +import { dynamicConfigServiceMock } from './config/dynamic_config_service.mock'; +import { IDynamicConfigService } from './config/dynamic_config_service'; function create({ env = Env.createDefault(REPO_ROOT, getEnvOptions()), logger = loggingSystemMock.create(), configService = configServiceMock.create(), + dynamicConfigService = dynamicConfigServiceMock.create(), }: { env?: Env; logger?: jest.Mocked; configService?: jest.Mocked; + dynamicConfigService?: jest.Mocked; } = {}): DeeplyMockedKeys { - return { coreId: Symbol(), env, logger, configService }; + return { coreId: Symbol(), env, logger, configService, dynamicConfigService }; } export const mockCoreContext = { diff --git a/src/core/server/core_context.ts b/src/core/server/core_context.ts index 6c6474f51ea2..b3219a164899 100644 --- a/src/core/server/core_context.ts +++ b/src/core/server/core_context.ts @@ -30,6 +30,7 @@ import { IConfigService, Env } from './config'; import { LoggerFactory } from './logging'; +import { IDynamicConfigService } from './config/dynamic_config_service'; /** @internal */ export type CoreId = symbol; @@ -44,4 +45,5 @@ export interface CoreContext { env: Env; configService: IConfigService; logger: LoggerFactory; + dynamicConfigService: IDynamicConfigService; } diff --git a/src/core/server/core_route_handler_context.ts b/src/core/server/core_route_handler_context.ts index 92be535f61ab..547f3231c6c0 100644 --- a/src/core/server/core_route_handler_context.ts +++ b/src/core/server/core_route_handler_context.ts @@ -40,6 +40,11 @@ import { } from './opensearch'; import { Auditor } from './audit_trail'; import { InternalUiSettingsServiceStart, IUiSettingsClient } from './ui_settings'; +import { + AsyncLocalStorageContext, + IDynamicConfigurationClient, + InternalDynamicConfigServiceStart, +} from './config'; class CoreOpenSearchRouteHandlerContext { #client?: IScopedClusterClient; @@ -109,12 +114,30 @@ class CoreUiSettingsRouteHandlerContext { } } +class CoreDynamicConfigRouteHandlerContext { + #client?: IDynamicConfigurationClient; + #asyncLocalStore?: AsyncLocalStorageContext; + + constructor(private readonly dynamicConfigServiceStart: InternalDynamicConfigServiceStart) {} + + public get client() { + this.#client = this.dynamicConfigServiceStart.getClient(); + return this.#client; + } + + public get asyncLocalStore() { + this.#asyncLocalStore = this.dynamicConfigServiceStart.getAsyncLocalStore(); + return this.#asyncLocalStore; + } +} + export class CoreRouteHandlerContext { #auditor?: Auditor; readonly opensearch: CoreOpenSearchRouteHandlerContext; readonly savedObjects: CoreSavedObjectsRouteHandlerContext; readonly uiSettings: CoreUiSettingsRouteHandlerContext; + readonly dynamicConfig: CoreDynamicConfigRouteHandlerContext; constructor( private readonly coreStart: InternalCoreStart, @@ -132,6 +155,7 @@ export class CoreRouteHandlerContext { this.coreStart.uiSettings, this.savedObjects ); + this.dynamicConfig = new CoreDynamicConfigRouteHandlerContext(this.coreStart.dynamicConfig); } public get auditor() { diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index ce0161f04e22..96a41ef82b8d 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -45,6 +45,7 @@ import { getEnvOptions, configServiceMock } from '../config/mocks'; import { httpServerMock } from './http_server.mocks'; import { createCookieSessionStorageFactory } from './cookie_session_storage'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; let server: HttpService; @@ -53,6 +54,13 @@ let env: Env; let coreContext: CoreContext; const configService = configServiceMock.create(); const contextSetup = contextServiceMock.createSetupContract(); +const dynamicConfigServiceStart = dynamicConfigServiceMock.createInternalStartContract({ + getConfig: { + rules: [], + }, + bulkGetConfigs: new Map(), + listConfigs: new Map(), +}); const setupDeps = { context: contextSetup, @@ -85,8 +93,15 @@ configService.atPath.mockReturnValue( beforeEach(() => { logger = loggingSystemMock.create(); env = Env.createDefault(REPO_ROOT, getEnvOptions()); - - coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; + const dynamicConfigService = dynamicConfigServiceMock.create(); + + coreContext = { + coreId: Symbol(), + env, + logger, + configService: configService as any, + dynamicConfigService, + }; server = new HttpService(coreContext); }); @@ -149,7 +164,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -186,7 +201,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -217,7 +232,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200, { value: null }); @@ -247,7 +262,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener) .get('/') @@ -292,7 +307,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener) .get('/') @@ -422,7 +437,7 @@ describe('Cookie based SessionStorage', () => { innerServer, cookieOptions ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -477,7 +492,7 @@ describe('Cookie based SessionStorage', () => { name: `sid-${sameSite}`, sameSite, }); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); const response = await supertest(innerServer.listener).get('/').expect(200); diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 4db4c4fac17f..8d3a798f0f08 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -48,6 +48,7 @@ import { HttpServer } from './http_server'; import { Readable } from 'stream'; import { RequestHandlerContext } from 'opensearch-dashboards/server'; import { OSD_CERT_PATH, OSD_KEY_PATH } from '@osd/dev-utils'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const cookieOptions = { name: 'sid', @@ -63,6 +64,7 @@ let configWithSSL: HttpConfig; const loggingService = loggingSystemMock.create(); const logger = loggingService.get(); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); let certificate: string; let key: string; @@ -110,7 +112,7 @@ test('log listening address after started', async () => { expect(server.isListening()).toBe(false); await server.setup(config); - await server.start(); + await server.start(dynamicConfigService); expect(server.isListening()).toBe(true); expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` @@ -125,8 +127,12 @@ test('log listening address after started', async () => { test('log listening address after started when configured with BasePath and rewriteBasePath = false', async () => { expect(server.isListening()).toBe(false); - await server.setup({ ...config, basePath: '/bar', rewriteBasePath: false }); - await server.start(); + await server.setup({ + ...config, + basePath: '/bar', + rewriteBasePath: false, + }); + await server.start(dynamicConfigService); expect(server.isListening()).toBe(true); expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` @@ -141,8 +147,12 @@ test('log listening address after started when configured with BasePath and rewr test('log listening address after started when configured with BasePath and rewriteBasePath = true', async () => { expect(server.isListening()).toBe(false); - await server.setup({ ...config, basePath: '/bar', rewriteBasePath: true }); - await server.start(); + await server.setup({ + ...config, + basePath: '/bar', + rewriteBasePath: true, + }); + await server.start(dynamicConfigService); expect(server.isListening()).toBe(true); expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` @@ -174,7 +184,7 @@ test('valid params', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/foo/some-string') @@ -204,7 +214,7 @@ test('invalid params', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/foo/some-string') @@ -239,7 +249,7 @@ test('valid query', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/foo/?bar=test&quux=123') @@ -269,7 +279,7 @@ test('invalid query', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/foo/?bar=test') @@ -304,7 +314,7 @@ test('valid body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -342,7 +352,7 @@ test('valid body with validate function', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -385,7 +395,7 @@ test('not inline validation - specifying params', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -428,7 +438,7 @@ test('not inline validation - specifying validation handler', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -478,7 +488,7 @@ test('not inline handler - OpenSearchDashboardsRequest', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -527,7 +537,7 @@ test('not inline handler - RequestHandler', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -561,7 +571,7 @@ test('invalid body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/foo/') @@ -596,7 +606,7 @@ test('handles putting', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .put('/foo/') @@ -627,7 +637,7 @@ test('handles deleting', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .delete('/foo/3') @@ -657,7 +667,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { const { registerRouter, server: innerServer } = await server.setup(configWithBasePath); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); innerServerListener = innerServer.listener; }); @@ -712,7 +722,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { const { registerRouter, server: innerServer } = await server.setup(configWithBasePath); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); innerServerListener = innerServer.listener; }); @@ -759,7 +769,7 @@ test('with defined `redirectHttpFromPort`', async () => { const { registerRouter } = await server.setup(configWithSSL); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); }); test('returns server and connection options on start', async () => { @@ -774,7 +784,7 @@ test('returns server and connection options on start', async () => { }); test('throws an error if starts without set up', async () => { - await expect(server.start()).rejects.toThrowErrorMatchingInlineSnapshot( + await expect(server.start(dynamicConfigService)).rejects.toThrowErrorMatchingInlineSnapshot( `"Http server is not setup up yet"` ); }); @@ -792,7 +802,7 @@ test('allows attaching metadata to attach meta-data tag strings to a route', asy ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).get('/with-tags').expect(200, { tags }); await supertest(innerServer.listener).get('/without-tags').expect(200, { tags: [] }); @@ -805,7 +815,7 @@ test('exposes route details of incoming request to a route handler', async () => router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: req.route })); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/') .expect(200, { @@ -831,7 +841,7 @@ describe('conditional compression', () => { }; router.get({ path: '/', validate: false }, (_context, _req, res) => res.ok(largeRequest)); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); return innerServer.listener; } @@ -910,7 +920,7 @@ describe('conditional compression', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); const response = await supertest(innerServer.listener) .get('/') .set('Connection', 'keep-alive') @@ -929,7 +939,7 @@ describe('conditional compression', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); const response = await supertest(innerServer.listener).get('/').expect(200); const restHeaders = omit(response.header, ['date', 'content-length']); @@ -959,7 +969,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/') .send({ test: 1 }) @@ -998,7 +1008,7 @@ describe('body options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(415, { statusCode: 415, error: 'Unsupported Media Type', @@ -1020,7 +1030,7 @@ describe('body options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(413, { statusCode: 413, error: 'Request Entity Too Large', @@ -1050,7 +1060,7 @@ describe('body options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(200, { parse: false, maxBytes: 1024, // hapi populates the default @@ -1088,7 +1098,7 @@ describe('timeout options', () => { } ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .post('/') .send({ test: 1 }) @@ -1126,7 +1136,7 @@ describe('timeout options', () => { } ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .delete('/') .expect(200, { @@ -1163,7 +1173,7 @@ describe('timeout options', () => { } ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .put('/') .expect(200, { @@ -1200,7 +1210,7 @@ describe('timeout options', () => { } ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .patch('/') .expect(200, { @@ -1234,7 +1244,7 @@ describe('timeout options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/') .send() @@ -1268,7 +1278,7 @@ describe('timeout options', () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener) .get('/') .send() @@ -1302,7 +1312,7 @@ describe('timeout options', () => { registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); }); }); @@ -1327,7 +1337,7 @@ test('should return a stream in the body', async () => { ); registerRouter(router); - await server.start(); + await server.start(dynamicConfigService); await supertest(innerServer.listener).put('/').send({ test: 1 }).expect(200, { parse: true, maxBytes: 1024, // hapi populates the default diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index c0d2caaab727..a3ec4e951c68 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -55,6 +55,7 @@ import { IsAuthenticated, AuthStateStorage, GetAuthState } from './auth_state_st import { AuthHeadersStorage, GetAuthHeaders } from './auth_headers_storage'; import { BasePath } from './base_path_service'; import { HttpServiceSetup, HttpServerInfo } from './types'; +import { InternalDynamicConfigServiceStart } from '../config'; /** @internal */ export interface HttpServerSetup { @@ -165,7 +166,7 @@ export class HttpServer { }; } - public async start() { + public async start(dynamicConfigService: InternalDynamicConfigServiceStart) { if (this.server === undefined) { throw new Error('Http server is not setup up yet'); } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 5a35be5d3c33..500466f30b21 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -99,6 +99,7 @@ const createInternalSetupContractMock = () => { start: jest.fn(), stop: jest.fn(), config: jest.fn().mockReturnValue(configMock.create()), + ext: jest.fn(), // @ts-expect-error it thinks that `Server` isn't a `Construtable` } as unknown) as jest.MockedClass, createCookieSessionStorageFactory: jest.fn(), diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 4402bc1fb573..7b26a46bfe61 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -41,10 +41,13 @@ import { ConfigService, Env } from '../config'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { contextServiceMock } from '../context/context_service.mock'; import { config as cspConfig } from '../csp'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const logger = loggingSystemMock.create(); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); const coreId = Symbol(); +const dynamicConfigService = dynamicConfigServiceMock.create(); +const dynamicConfigServiceStart = dynamicConfigServiceMock.createInternalStartContract(); const createConfigService = (value: Partial = {}) => { const configService = new ConfigService( @@ -90,7 +93,7 @@ test('creates and sets up http server', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); expect(mockHttpServer.mock.instances.length).toBe(1); @@ -100,7 +103,7 @@ test('creates and sets up http server', async () => { expect(httpServer.setup).toHaveBeenCalled(); expect(httpServer.start).not.toHaveBeenCalled(); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).toHaveBeenCalled(); }); @@ -129,6 +132,7 @@ test('spins up notReady server until started if configured with `autoListen:true configService, env: Env.createDefault(REPO_ROOT, getEnvOptions()), logger, + dynamicConfigService, }); await service.setup(setupDeps); @@ -150,7 +154,7 @@ test('spins up notReady server until started if configured with `autoListen:true header: mockResponse.header.mock.calls, }).toMatchSnapshot('503 response'); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).toBeCalledTimes(1); expect(notReadyHapiServer.stop).toBeCalledTimes(1); @@ -167,7 +171,7 @@ test('logs error if already set up', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); await service.setup(setupDeps); @@ -185,10 +189,10 @@ test('stops http server', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.stop).toHaveBeenCalledTimes(0); @@ -212,7 +216,7 @@ test('stops not ready server if it is running', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); await service.setup(setupDeps); @@ -235,7 +239,7 @@ test('register route handler', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); const { createRouter } = await service.setup(setupDeps); const router = createRouter('/foo'); @@ -254,7 +258,7 @@ test('returns http server contract on setup', async () => { stop: noop, })); - const service = new HttpService({ coreId, configService, env, logger }); + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); const setupContract = await service.setup(setupDeps); expect(setupContract).toMatchObject(httpServer); expect(setupContract).toMatchObject({ @@ -283,10 +287,11 @@ test('does not start http server if process is dev cluster master (deprecated) o }) ), logger, + dynamicConfigService, }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).not.toHaveBeenCalled(); }); @@ -312,10 +317,11 @@ test('does not start http server if process is dev cluster manager', async () => }) ), logger, + dynamicConfigService, }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).not.toHaveBeenCalled(); }); @@ -341,10 +347,11 @@ test('does not start http server if process is dev cluster master (deprecated)', }) ), logger, + dynamicConfigService, }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).not.toHaveBeenCalled(); }); @@ -366,10 +373,11 @@ test('does not start http server if configured with `autoListen:false`', async ( configService, env: Env.createDefault(REPO_ROOT, getEnvOptions()), logger, + dynamicConfigService, }); await service.setup(setupDeps); - await service.start(); + await service.start({ dynamicConfigService: dynamicConfigServiceStart }); expect(httpServer.start).not.toHaveBeenCalled(); }); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index ed1da8754721..e139fa1dee2e 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -36,7 +36,7 @@ import { pick } from '@osd/std'; import { CoreService } from '../../types'; import { Logger, LoggerFactory } from '../logging'; import { ContextSetup } from '../context'; -import { Env } from '../config'; +import { Env, InternalDynamicConfigServiceStart } from '../config'; import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; @@ -60,6 +60,10 @@ export interface SetupDeps { context: ContextSetup; } +export interface StartDeps { + dynamicConfigService: InternalDynamicConfigServiceStart; +} + /** @internal */ export class HttpService implements CoreService { @@ -140,7 +144,7 @@ export class HttpService }; } - public async start() { + public async start(deps: StartDeps) { const config = await this.config$.pipe(first()).toPromise(); if (this.shouldListen(config)) { if (this.notReadyServer) { @@ -154,7 +158,7 @@ export class HttpService await this.httpsRedirectServer.start(config); } - await this.httpServer.start(); + await this.httpServer.start(deps.dynamicConfigService); } return this.getStartContract(); diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 82339127ad39..7ce4112fde2c 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -28,6 +28,8 @@ * under the License. */ +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; + jest.mock('fs', () => { const original = jest.requireActual('fs'); return { @@ -121,10 +123,12 @@ describe('timeouts', () => { allowFromAnyIp: true, ipAllowlist: [], }, + updateConfigs: jest.fn(), } as any); registerRouter(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start(dynamicConfigService); expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up'); diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index 17fc7f74062a..9cbd6cca2155 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -38,6 +38,7 @@ import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; let server: HttpService; @@ -49,6 +50,8 @@ const setupDeps = { context: contextSetup, }; +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + beforeEach(() => { logger = loggingSystemMock.create(); server = createHttpServer({ logger }); @@ -87,7 +90,7 @@ describe('OnPreRouting', () => { callingOrder.push('second'); return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, 'ok'); @@ -120,7 +123,7 @@ describe('OnPreRouting', () => { return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/initial').expect(200, 'redirected'); @@ -144,7 +147,7 @@ describe('OnPreRouting', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/initial').expect(302); @@ -166,7 +169,7 @@ describe('OnPreRouting', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -184,7 +187,7 @@ describe('OnPreRouting', () => { registerOnPreRouting((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -207,7 +210,7 @@ describe('OnPreRouting', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPreRouting((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -244,7 +247,7 @@ describe('OnPreRouting', () => { res.ok({ body: { customField: String((req as any).customField) } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); }); @@ -267,7 +270,7 @@ describe('OnPreAuth', () => { callingOrder.push('second'); return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, 'ok'); @@ -288,7 +291,7 @@ describe('OnPreAuth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/initial').expect(302); @@ -308,7 +311,7 @@ describe('OnPreAuth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -324,7 +327,7 @@ describe('OnPreAuth', () => { registerOnPreAuth((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -345,7 +348,7 @@ describe('OnPreAuth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPreAuth((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -380,7 +383,7 @@ describe('OnPreAuth', () => { res.ok({ body: { customField: String(req.customField) } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); }); @@ -406,7 +409,7 @@ describe('OnPreAuth', () => { (context, req, res) => res.ok({ body: req.body.term }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -436,7 +439,7 @@ describe('OnPostAuth', () => { callingOrder.push('second'); return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, 'ok'); @@ -457,7 +460,7 @@ describe('OnPostAuth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/initial').expect(302); @@ -476,7 +479,7 @@ describe('OnPostAuth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -491,7 +494,7 @@ describe('OnPostAuth', () => { registerOnPostAuth((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -511,7 +514,7 @@ describe('OnPostAuth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPostAuth((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -547,7 +550,7 @@ describe('OnPostAuth', () => { res.ok({ body: { customField: String((req as any).customField) } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); }); @@ -573,7 +576,7 @@ describe('OnPostAuth', () => { (context, req, res) => res.ok({ body: req.body.term }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -610,7 +613,7 @@ describe('Auth', () => { res.ok({ body: { content: 'ok' } }) ); registerAuth((req, res, t) => t.authenticated()); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { content: 'ok' }); }); @@ -623,7 +626,7 @@ describe('Auth', () => { res.ok({ body: { content: 'ok' } }) ); registerAuth((req, res, t) => t.notHandled()); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -640,7 +643,7 @@ describe('Auth', () => { const authenticate = jest.fn().mockImplementation((req, res, t) => t.authenticated()); registerAuth(authenticate); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: true }); expect(authenticate).toHaveBeenCalledTimes(1); @@ -658,7 +661,7 @@ describe('Auth', () => { const authenticate = jest.fn(); registerAuth(authenticate); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: false }); expect(authenticate).toHaveBeenCalledTimes(0); @@ -676,7 +679,7 @@ describe('Auth', () => { const authenticate = jest.fn().mockImplementation((req, res, t) => t.authenticated({})); await registerAuth(authenticate); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: true }); expect(authenticate).toHaveBeenCalledTimes(1); @@ -688,7 +691,7 @@ describe('Auth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerAuth((req, res) => res.unauthorized()); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(401); }); @@ -704,7 +707,7 @@ describe('Auth', () => { location: redirectTo, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(302); expect(response.header.location).toBe(redirectTo); @@ -716,7 +719,7 @@ describe('Auth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerAuth((req, res, t) => t.redirected({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(500); }); @@ -729,7 +732,7 @@ describe('Auth', () => { registerAuth((req, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -764,7 +767,7 @@ describe('Auth', () => { return toolkit.authenticated({ state: user }); }); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -808,7 +811,7 @@ describe('Auth', () => { sessionStorage.clear(); return res.ok(); }); - await server.start(); + await server.start({ dynamicConfigService }); const responseToSetCookie = await supertest(innerServer.listener).get('/').expect(200); @@ -857,7 +860,7 @@ describe('Auth', () => { fromRouteHandler = req.headers.authorization; return res.ok(); }); - await server.start(); + await server.start({ dynamicConfigService }); const token = 'Basic: user:password'; await supertest(innerServer.listener).get('/').set('Authorization', token).expect(200); @@ -880,7 +883,7 @@ describe('Auth', () => { }); router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -899,7 +902,7 @@ describe('Auth', () => { }); router.get({ path: '/', validate: false }, (context, req, res) => res.badRequest()); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(400); @@ -926,7 +929,7 @@ describe('Auth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(200); @@ -959,7 +962,7 @@ describe('Auth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const response = await supertest(innerServer.listener).get('/').expect(400); @@ -986,7 +989,7 @@ describe('Auth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/initial').expect(302); @@ -1006,7 +1009,7 @@ describe('Auth', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1021,7 +1024,7 @@ describe('Auth', () => { registerOnPostAuth((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1041,7 +1044,7 @@ describe('Auth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPostAuth((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1076,7 +1079,7 @@ describe('Auth', () => { res.ok({ body: { customField: String((req as any).customField) } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); }); @@ -1102,7 +1105,7 @@ describe('Auth', () => { (context, req, res) => res.ok({ body: req.body.term }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -1134,7 +1137,7 @@ describe('OnPreResponse', () => { callingOrder.push('second'); return t.next(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, 'ok'); @@ -1162,7 +1165,7 @@ describe('OnPreResponse', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); @@ -1186,7 +1189,7 @@ describe('OnPreResponse', () => { headers: { 'x-opensearch-dashboards-header': 'value' }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); @@ -1209,7 +1212,7 @@ describe('OnPreResponse', () => { registerOnPreResponse((req, res, t) => { throw new Error('reason'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1231,7 +1234,7 @@ describe('OnPreResponse', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); registerOnPreResponse((req, res, t) => ({} as any)); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1258,7 +1261,7 @@ describe('OnPreResponse', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); }); @@ -1286,7 +1289,7 @@ describe('OnPreResponse', () => { (context, req, res) => res.ok({ body: req.body.term }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -1317,7 +1320,7 @@ describe('OnPreResponse', () => { return t.render({ body: 'overridden' }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); @@ -1350,7 +1353,7 @@ describe('OnPreResponse', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); @@ -1398,7 +1401,7 @@ describe('run interceptors in the right order', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); expect(executionOrder).toEqual([ @@ -1442,7 +1445,7 @@ describe('run interceptors in the right order', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'onPostAuth', 'onPreResponse']); @@ -1485,7 +1488,7 @@ describe('run interceptors in the right order', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(403); expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'auth', 'onPreResponse']); diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index 0742e8c08d6d..e9bb062c8319 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -39,6 +39,7 @@ import { IRouter, RouteRegistrar } from '../router'; import { configServiceMock } from '../../config/mocks'; import { contextServiceMock } from '../../context/context_service.mock'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../../../../package.json'); @@ -53,6 +54,7 @@ const opensearchDashboardsName = 'my-opensearch-dashboards-name'; const setupDeps = { context: contextServiceMock.createSetupContract(), }; +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); describe('core lifecycle handlers', () => { let server: HttpService; @@ -99,7 +101,7 @@ describe('core lifecycle handlers', () => { router.get({ path: testRoute, validate: false }, (context, req, res) => { return res.ok({ body: 'ok' }); }); - await server.start(); + await server.start({ dynamicConfigService }); }); it('accepts requests with the correct version passed in the version header', async () => { @@ -132,7 +134,7 @@ describe('core lifecycle handlers', () => { router.get({ path: testErrorRoute, validate: false }, (context, req, res) => { return res.badRequest({ body: 'bad request' }); }); - await server.start(); + await server.start({ dynamicConfigService }); }); it('adds the osd-name header', async () => { @@ -203,7 +205,7 @@ describe('core lifecycle handlers', () => { ); }); - await server.start(); + await server.start({ dynamicConfigService }); }); nonDestructiveMethods.forEach((method) => { diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index 45eebb540553..23a271644c54 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -28,6 +28,8 @@ * under the License. */ +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; + jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'), })); @@ -49,6 +51,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, }; +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); beforeEach(() => { logger = loggingSystemMock.create(); @@ -71,7 +74,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: false, @@ -85,7 +88,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: false, @@ -99,7 +102,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: false, @@ -113,7 +116,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: true, @@ -127,7 +130,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { isAuthenticated: true, @@ -146,7 +149,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: false } }, (context, req, res) => res.ok({ body: { authRequired: req.route.options.authRequired } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: false, @@ -160,7 +163,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: { authRequired: req.route.options.authRequired } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: 'optional', @@ -174,7 +177,7 @@ describe('OpenSearchDashboardsRequest', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: { authRequired: req.route.options.authRequired } }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { authRequired: true, @@ -205,7 +208,7 @@ describe('OpenSearchDashboardsRequest', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const incomingRequest = supertest(innerServer.listener) .get('/') @@ -232,7 +235,7 @@ describe('OpenSearchDashboardsRequest', () => { return res.ok({ body: 'ok' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/'); @@ -255,7 +258,7 @@ describe('OpenSearchDashboardsRequest', () => { return res.badRequest(); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/'); @@ -288,7 +291,7 @@ describe('OpenSearchDashboardsRequest', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).post('/').send({ data: 'test' }).expect(200); @@ -316,7 +319,7 @@ describe('OpenSearchDashboardsRequest', () => { return res.ok({ body: 'ok' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); expect(nextSpy).toHaveBeenCalledTimes(1); @@ -343,7 +346,7 @@ describe('OpenSearchDashboardsRequest', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const incomingRequest = supertest(innerServer.listener) .get('/') @@ -377,7 +380,7 @@ describe('OpenSearchDashboardsRequest', () => { ); }); - await server.start(); + await server.start({ dynamicConfigService }); const incomingRequest = supertest(innerServer.listener) .post('/') @@ -398,7 +401,7 @@ describe('OpenSearchDashboardsRequest', () => { router.get({ path: '/', validate: false }, async (context, req, res) => { return res.ok({ body: { requestId: req.id } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const st = supertest(innerServer.listener); @@ -417,7 +420,7 @@ describe('OpenSearchDashboardsRequest', () => { router.get({ path: '/', validate: false }, async (context, req, res) => { return res.ok({ body: { requestUuid: req.uuid } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const st = supertest(innerServer.listener); diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index b1785042691f..0a4b1664029d 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -38,6 +38,7 @@ import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; let server: HttpService; @@ -47,6 +48,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, }; +const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); beforeEach(() => { logger = loggingSystemMock.create(); @@ -75,7 +77,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -102,7 +104,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: true, @@ -128,7 +130,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -146,7 +148,7 @@ describe('Options', () => { { path: '/', validate: false, options: { authRequired: 'optional' } }, (context, req, res) => res.ok({ body: 'ok' }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(401); }); @@ -173,7 +175,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -197,7 +199,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -224,7 +226,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: true, @@ -241,7 +243,7 @@ describe('Options', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: 'ok' }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(401); }); @@ -256,7 +258,7 @@ describe('Options', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: 'ok' }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(401); }); @@ -276,7 +278,7 @@ describe('Options', () => { { path: '/', validate: false, options: { authRequired: true } }, (context, req, res) => res.ok({ body: 'ok' }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(302); @@ -303,7 +305,7 @@ describe('Options', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200, { httpAuthIsAuthenticated: false, @@ -356,7 +358,7 @@ describe('Options', () => { return res.ok({}); } ); - await server.start(); + await server.start({ dynamicConfigService }); // start the request const request = supertest(innerServer.listener) @@ -381,7 +383,7 @@ describe('Options', () => { }, async (context, req, res) => res.ok({}) ); - await server.start(); + await server.start({ dynamicConfigService }); // start the request const request = supertest(innerServer.listener) @@ -416,7 +418,7 @@ describe('Options', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); // start the request const request = supertest(innerServer.listener) @@ -449,7 +451,7 @@ describe('Options', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); // start the request const request = supertest(innerServer.listener) @@ -483,7 +485,7 @@ describe('Options', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up'); }); @@ -508,7 +510,7 @@ describe('Options', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await expect(supertest(innerServer.listener).post('/a')).resolves.toHaveProperty( 'status', 200 @@ -524,7 +526,7 @@ describe('Cache-Control', () => { const router = createRouter('/'); router.get({ path: '/', validate: false, options: {} }, (context, req, res) => res.ok()); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .get('/') @@ -542,7 +544,7 @@ describe('Cache-Control', () => { }, }) ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect('Cache-Control', 'public, max-age=1200'); }); @@ -556,7 +558,7 @@ describe('Handler', () => { router.get({ path: '/', validate: false }, (context, req, res) => { throw new Error('unexpected error'); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -577,7 +579,7 @@ describe('Handler', () => { router.get({ path: '/', validate: false }, (context, req, res) => { throw Boom.unauthorized(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -596,7 +598,7 @@ describe('Handler', () => { const router = createRouter('/'); router.get({ path: '/', validate: false }, (context, req, res) => 'ok' as any); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -625,7 +627,7 @@ describe('Handler', () => { }, (context, req, res) => res.noContent() ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener) .get('/') @@ -656,7 +658,7 @@ describe('Handler', () => { return res.ok({ body: 'ok' }); } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/') @@ -683,7 +685,7 @@ describe('Handler', () => { return res.ok({ body: 'ok' }); } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).post('/').type('json').send('12').expect(200); @@ -702,7 +704,7 @@ describe('handleLegacyErrors', () => { throw Boom.notFound(); }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(404); @@ -722,7 +724,7 @@ describe('handleLegacyErrors', () => { throw new Error('Unexpected'); }) ); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -744,7 +746,7 @@ describe('Response factory', () => { return res.ok({ body: { key: 'value' } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); @@ -760,7 +762,7 @@ describe('Response factory', () => { return res.ok({ body: 'result' }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); @@ -776,7 +778,7 @@ describe('Response factory', () => { return res.ok(undefined); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(200); }); @@ -798,7 +800,7 @@ describe('Response factory', () => { return res.ok({ body: stream }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); @@ -826,7 +828,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.text).toBe('abc'); @@ -851,7 +853,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.text).toBe('abc'); @@ -871,7 +873,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200).buffer(true); expect(result.header['content-encoding']).toBe('binary'); @@ -891,7 +893,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200).buffer(true); expect(result.text).toBe('abc'); @@ -910,7 +912,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.text).toEqual('value'); @@ -929,7 +931,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.text).toEqual('value'); @@ -948,7 +950,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.header.etag).toBe('1234'); @@ -965,7 +967,7 @@ describe('Response factory', () => { }, }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.header['set-cookie']).toEqual(['foo', 'bar']); @@ -979,7 +981,7 @@ describe('Response factory', () => { payload.key.payload = payload; return res.ok({ body: payload }); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(500); // error happens within hapi when route handler already finished execution. @@ -992,7 +994,7 @@ describe('Response factory', () => { router.get({ path: '/', validate: false }, (context, req, res) => { return res.ok({ body: { key: 'value' } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(200); expect(result.body).toEqual({ key: 'value' }); @@ -1005,7 +1007,7 @@ describe('Response factory', () => { router.get({ path: '/', validate: false }, (context, req, res) => { return res.accepted({ body: { location: 'somewhere' } }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(202); expect(result.body).toEqual({ location: 'somewhere' }); @@ -1018,7 +1020,7 @@ describe('Response factory', () => { router.get({ path: '/', validate: false }, (context, req, res) => { return res.noContent(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(204); expect(result.noContent).toBe(true); @@ -1040,7 +1042,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(302); @@ -1061,7 +1063,7 @@ describe('Response factory', () => { } as any); // location headers is required }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1086,7 +1088,7 @@ describe('Response factory', () => { return res.badRequest({ body: error }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(400); @@ -1105,7 +1107,7 @@ describe('Response factory', () => { return res.badRequest(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(400); @@ -1126,7 +1128,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(400); @@ -1162,7 +1164,7 @@ describe('Response factory', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/foo/') @@ -1210,7 +1212,7 @@ describe('Response factory', () => { } ); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener) .post('/foo/') @@ -1264,7 +1266,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1280,7 +1282,7 @@ describe('Response factory', () => { return res.unauthorized(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1296,7 +1298,7 @@ describe('Response factory', () => { return res.forbidden({ body: error }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(403); @@ -1311,7 +1313,7 @@ describe('Response factory', () => { return res.forbidden(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(403); @@ -1327,7 +1329,7 @@ describe('Response factory', () => { return res.notFound({ body: error }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(404); @@ -1342,7 +1344,7 @@ describe('Response factory', () => { return res.notFound(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(404); @@ -1358,7 +1360,7 @@ describe('Response factory', () => { return res.conflict({ body: error }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(409); @@ -1373,7 +1375,7 @@ describe('Response factory', () => { return res.conflict(); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(409); @@ -1392,7 +1394,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(418); @@ -1416,7 +1418,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1440,7 +1442,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1463,7 +1465,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1497,7 +1499,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(201); @@ -1518,7 +1520,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(301); @@ -1537,7 +1539,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); await supertest(innerServer.listener).get('/').expect(500); @@ -1562,7 +1564,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1583,7 +1585,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1609,7 +1611,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1633,7 +1635,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(401); @@ -1651,7 +1653,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1670,7 +1672,7 @@ describe('Response factory', () => { }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1694,7 +1696,7 @@ describe('Response factory', () => { } as any); // requires error message }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1717,7 +1719,7 @@ describe('Response factory', () => { return res.custom({ body: error } as any); // options.statusCode is required }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); @@ -1740,7 +1742,7 @@ describe('Response factory', () => { return res.custom({ body: error, statusCode: 20 }); }); - await server.start(); + await server.start({ dynamicConfigService }); const result = await supertest(innerServer.listener).get('/').expect(500); diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index bf2063368cd7..0a805e54b968 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -36,6 +36,7 @@ import { HttpService } from './http_service'; import { CoreContext } from '../core_context'; import { getEnvOptions, configServiceMock } from '../config/mocks'; import { loggingSystemMock } from '../logging/logging_system.mock'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const coreId = Symbol('core'); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); @@ -66,11 +67,14 @@ configService.atPath.mockReturnValue( } as any) ); +const dynamicConfigService = dynamicConfigServiceMock.create(); + const defaultContext: CoreContext = { coreId, env, logger, configService, + dynamicConfigService, }; export const createCoreContext = (overrides: Partial = {}): CoreContext => ({ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 66d856642e32..e5c2f4386f67 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -70,6 +70,12 @@ import { SavedObjectsServiceSetup, SavedObjectsServiceStart, } from './saved_objects'; +import { + AsyncLocalStorageContext, + DynamicConfigServiceSetup, + DynamicConfigServiceStart, + IDynamicConfigurationClient, +} from './config'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; @@ -102,6 +108,13 @@ export { ConfigDeprecationFactory, EnvironmentMode, PackageInfo, + IDynamicConfigurationClient, + DynamicConfigurationClientOptions, + ConfigIdentifier, + GetConfigProps, + BulkGetConfigProps, + IDynamicConfigStoreClient, + IDynamicConfigStoreClientFactory, } from './config'; export { IContextContainer, @@ -399,6 +412,7 @@ export { CoreUsageDataStart } from './core_usage_data'; * - {@link IUiSettingsClient | uiSettings.client} - uiSettings client * which uses the credentials of the incoming request * - {@link Auditor | uiSettings.auditor} - AuditTrail client scoped to the incoming request + * - {@link IDynamicConfigurationClient | dynamicConfig.client} - Dynamic configuration client * * @public */ @@ -417,6 +431,10 @@ export interface RequestHandlerContext { uiSettings: { client: IUiSettingsClient; }; + dynamicConfig: { + client: IDynamicConfigurationClient; + asyncLocalStore: AsyncLocalStorageContext | undefined; + }; auditor: Auditor; }; } @@ -458,6 +476,8 @@ export interface CoreSetup; /** {@link AuditTrailSetup} */ auditTrail: AuditTrailSetup; + /** {@link DynamicConfigServiceSetup} */ + dynamicConfigService: DynamicConfigServiceSetup; } /** @@ -497,6 +517,8 @@ export interface CoreStart { coreUsageData: CoreUsageDataStart; /** {@link CrossCompatibilityServiceStart} */ crossCompatibility: CrossCompatibilityServiceStart; + /** {@link DynamicConfigServiceStart} */ + dynamicConfig: DynamicConfigServiceStart; } export { diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 105f84dd9916..571dc3ad6ca0 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -31,7 +31,11 @@ import { Type } from '@osd/config-schema'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; -import { ConfigDeprecationProvider } from './config'; +import { + ConfigDeprecationProvider, + InternalDynamicConfigServiceSetup, + InternalDynamicConfigServiceStart, +} from './config'; import { ContextSetup } from './context'; import { InternalOpenSearchServiceSetup, InternalOpenSearchServiceStart } from './opensearch'; import { InternalHttpServiceSetup, InternalHttpServiceStart } from './http'; @@ -67,6 +71,7 @@ export interface InternalCoreSetup { logging: InternalLoggingServiceSetup; metrics: InternalMetricsServiceSetup; security: InternalSecurityServiceSetup; + dynamicConfig: InternalDynamicConfigServiceSetup; } /** @@ -82,6 +87,7 @@ export interface InternalCoreStart { auditTrail: AuditTrailStart; coreUsageData: CoreUsageDataStart; crossCompatibility: CrossCompatibilityServiceStart; + dynamicConfig: InternalDynamicConfigServiceStart; } /** diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 3dd4ce6589bd..7fc8729264ce 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -28,6 +28,8 @@ * under the License. */ +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; + jest.mock('../../../legacy/server/osd_server'); jest.mock('./cluster_manager'); @@ -75,6 +77,7 @@ let startDeps: LegacyServiceStartDeps; const logger = loggingSystemMock.create(); let configService: ReturnType; let environmentSetup: ReturnType; +const dynamicConfigService = dynamicConfigServiceMock.create(); beforeEach(() => { coreId = Symbol(); @@ -110,6 +113,7 @@ beforeEach(() => { logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), security: securityServiceMock.createSetupContract(), + dynamicConfig: dynamicConfigServiceMock.createInternalSetupContract(), }, plugins: { 'plugin-id': 'plugin-value' }, uiPlugins: { @@ -159,6 +163,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); @@ -191,6 +196,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); @@ -221,6 +227,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); @@ -241,6 +248,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await expect(legacyService.setupLegacyConfig()).rejects.toThrowErrorMatchingInlineSnapshot( @@ -263,6 +271,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); @@ -284,6 +293,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); @@ -309,6 +319,7 @@ describe('once LegacyService is set up with connection info', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); @@ -329,7 +340,13 @@ describe('once LegacyService is set up with connection info', () => { describe('once LegacyService is set up without connection info', () => { let legacyService: LegacyService; beforeEach(async () => { - legacyService = new LegacyService({ coreId, env, logger, configService: configService as any }); + legacyService = new LegacyService({ + coreId, + env, + logger, + configService: configService as any, + dynamicConfigService, + }); await legacyService.setupLegacyConfig(); await legacyService.setup(setupDeps); await legacyService.start(startDeps); @@ -383,6 +400,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { ), logger, configService: configService as any, + dynamicConfigService, }); await devClusterLegacyService.setupLegacyConfig(); @@ -413,6 +431,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { ), logger, configService: configService as any, + dynamicConfigService, }); await devClusterLegacyService.setupLegacyConfig(); @@ -438,6 +457,7 @@ describe('start', () => { env, logger, configService: configService as any, + dynamicConfigService, }); await expect(legacyService.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( `"Legacy service is not setup yet."` @@ -452,6 +472,7 @@ test('Sets the server.uuid property on the legacy configuration', async () => { env, logger, configService: configService as any, + dynamicConfigService, }); environmentSetup.instanceUuid = 'UUID_FROM_SERVICE'; diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 4dec545fd186..1dd9be84a663 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -234,6 +234,11 @@ export class LegacyService implements CoreService { }, }, crossCompatibility: startDeps.core.crossCompatibility, + dynamicConfig: { + getClient: startDeps.core.dynamicConfig.getClient, + getAsyncLocalStore: startDeps.core.dynamicConfig.getAsyncLocalStore, + createStoreFromRequest: startDeps.core.dynamicConfig.createStoreFromRequest, + }, }; const router = setupDeps.core.http.createRouter('', this.legacyId); diff --git a/src/core/server/metrics/integration_tests/server_collector.test.ts b/src/core/server/metrics/integration_tests/server_collector.test.ts index ef0b2fc23ae7..bcf703adc854 100644 --- a/src/core/server/metrics/integration_tests/server_collector.test.ts +++ b/src/core/server/metrics/integration_tests/server_collector.test.ts @@ -36,6 +36,7 @@ import { createHttpServer } from '../../http/test_utils'; import { HttpService, IRouter } from '../../http'; import { contextServiceMock } from '../../context/context_service.mock'; import { ServerMetricsCollector } from '../collectors/server'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; const requestWaitDelay = 25; @@ -44,6 +45,7 @@ describe('ServerMetricsCollector', () => { let collector: ServerMetricsCollector; let hapiServer: HapiServer; let router: IRouter; + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sendGet = (path: string) => supertest(hapiServer.listener).get(path); @@ -65,7 +67,7 @@ describe('ServerMetricsCollector', () => { router.get({ path: '/', validate: false }, async (ctx, req, res) => { return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); let metrics = await collector.collect(); @@ -103,7 +105,7 @@ describe('ServerMetricsCollector', () => { await never; return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await sendGet('/'); const discoReq1 = sendGet('/disconnect').end(() => null); @@ -159,7 +161,7 @@ describe('ServerMetricsCollector', () => { await delay(250); return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await Promise.all([sendGet('/no-delay'), sendGet('/250-ms')]); let metrics = await collector.collect(); @@ -183,7 +185,7 @@ describe('ServerMetricsCollector', () => { await waitSubject.pipe(take(1)).toPromise(); return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); const waitForHits = (hits: number) => hitSubject @@ -222,7 +224,7 @@ describe('ServerMetricsCollector', () => { router.get({ path: '/', validate: false }, async (ctx, req, res) => { return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await sendGet('/'); await sendGet('/'); @@ -272,7 +274,7 @@ describe('ServerMetricsCollector', () => { return res.ok({ body: '' }); }); - await server.start(); + await server.start({ dynamicConfigService }); await Promise.all([sendGet('/no-delay'), sendGet('/500-ms')]); let metrics = await collector.collect(); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index d688afbd5450..e9315a620e84 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -52,6 +52,7 @@ import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; import { securityServiceMock } from './security/security_service.mock'; import { crossCompatibilityServiceMock } from './cross_compatibility/cross_compatibility.mock'; +import { dynamicConfigServiceMock } from './config/dynamic_config_service.mock'; export { configServiceMock } from './config/mocks'; export { httpServerMock } from './http/http_server.mocks'; @@ -167,6 +168,7 @@ function createCoreSetupMock({ .fn, object, any]>, []>() .mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]), security: securityServiceMock.createSetupContract(), + dynamicConfigService: dynamicConfigServiceMock.createSetupContract(), }; return mock; @@ -183,6 +185,7 @@ function createCoreStartMock() { uiSettings: uiSettingsServiceMock.createStartContract(), coreUsageData: coreUsageDataServiceMock.createStartContract(), crossCompatibility: crossCompatibilityServiceMock.createStartContract(), + dynamicConfig: dynamicConfigServiceMock.createStartContract(), }; return mock; @@ -204,6 +207,7 @@ function createInternalCoreSetupMock() { logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), security: securityServiceMock.createSetupContract(), + dynamicConfig: dynamicConfigServiceMock.createInternalSetupContract(), }; return setupDeps; } @@ -219,6 +223,7 @@ function createInternalCoreStartMock() { auditTrail: auditTrailServiceMock.createStartContract(), coreUsageData: coreUsageDataServiceMock.createStartContract(), crossCompatibility: crossCompatibilityServiceMock.createStartContract(), + dynamicConfig: dynamicConfigServiceMock.createInternalStartContract(), }; return startDeps; } @@ -239,6 +244,10 @@ function createCoreRequestHandlerContextMock() { client: uiSettingsServiceMock.createClient(), }, auditor: auditTrailServiceMock.createAuditor(), + dynamicConfig: { + client: dynamicConfigServiceMock.createInternalStartContract().getClient(), + asyncLocalStore: dynamicConfigServiceMock.createInternalStartContract().getAsyncLocalStore(), + }, }; } diff --git a/src/core/server/opensearch/opensearch_service.test.ts b/src/core/server/opensearch/opensearch_service.test.ts index 1280479314a6..69bf16071eaf 100644 --- a/src/core/server/opensearch/opensearch_service.test.ts +++ b/src/core/server/opensearch/opensearch_service.test.ts @@ -43,12 +43,14 @@ import { OpenSearchService } from './opensearch_service'; import { opensearchServiceMock } from './opensearch_service.mock'; import { opensearchClientMock } from './client/mocks'; import { duration } from 'moment'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const delay = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs)); let opensearchService: OpenSearchService; const configService = configServiceMock.create(); +const dynamicConfigService = dynamicConfigServiceMock.create(); const setupDeps = { http: httpServiceMock.createInternalSetupContract(), }; @@ -77,7 +79,13 @@ let mockLegacyClusterClientInstance: ReturnType { env = Env.createDefault(REPO_ROOT, getEnvOptions()); - coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; + coreContext = { + coreId: Symbol(), + env, + logger, + configService: configService as any, + dynamicConfigService, + }; opensearchService = new OpenSearchService(coreContext); MockLegacyClusterClient.mockClear(); diff --git a/src/core/server/plugins/discovery/plugins_discovery.test.ts b/src/core/server/plugins/discovery/plugins_discovery.test.ts index 550acaf3f4f3..0f3db651defc 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.test.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.test.ts @@ -43,6 +43,7 @@ import type { InstanceInfo } from '../plugin_context'; import { discover } from './plugins_discovery'; import { CoreContext } from '../../core_context'; import { PROCESS_WORKING_DIR, standardize } from '@osd/cross-platform'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; const Plugins = { invalid: () => ({ @@ -117,12 +118,14 @@ describe('plugins discovery system', () => { logger ); await configService.setSchema(config.path, config.schema); + const dynamicConfigService = dynamicConfigServiceMock.create(); coreContext = { coreId: Symbol(), configService, env, logger, + dynamicConfigService, }; pluginConfig = await configService @@ -408,6 +411,7 @@ describe('plugins discovery system', () => { cliArgs: { dev: false, envName: 'development' }, }) ); + const dynamicConfigService = dynamicConfigServiceMock.create(); discover( new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), @@ -416,6 +420,7 @@ describe('plugins discovery system', () => { configService, env, logger, + dynamicConfigService, }, instanceInfo ); @@ -436,6 +441,7 @@ describe('plugins discovery system', () => { cliArgs: { dev: false, envName: 'production' }, }) ); + const dynamicConfigService = dynamicConfigServiceMock.create(); discover( new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), @@ -444,6 +450,7 @@ describe('plugins discovery system', () => { configService, env, logger, + dynamicConfigService, }, instanceInfo ); diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index ea400ddcd913..c30d78a18249 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -44,6 +44,7 @@ import { environmentServiceMock } from '../../environment/environment_service.mo import { coreMock } from '../../mocks'; import { Plugin, CompatibleEnginePluginVersions } from '../types'; import { PluginWrapper } from '../plugin'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; describe('PluginsService', () => { const logger = loggingSystemMock.create(); @@ -117,12 +118,14 @@ describe('PluginsService', () => { const rawConfigService = rawConfigServiceMock.create({ rawConfig$: config$ }); const configService = new ConfigService(rawConfigService, env, logger); await configService.setSchema(config.path, config.schema); + const dynamicConfigService = dynamicConfigServiceMock.create(); pluginsService = new PluginsService({ coreId: Symbol('core'), env, logger, configService, + dynamicConfigService, }); }); diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index 2fc8a182a860..f1e91c9eb6f7 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -46,6 +46,7 @@ import { createPluginSetupContext, InstanceInfo, } from './plugin_context'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const { join } = posix; const mockPluginInitializer = jest.fn(); @@ -83,6 +84,7 @@ function createPluginManifest(manifestProps: Partial = {}): Plug }; } +const dynamicConfigService = dynamicConfigServiceMock.create(); const configService = configServiceMock.create(); configService.atPath.mockReturnValue(new BehaviorSubject({ initialize: true })); @@ -100,7 +102,7 @@ beforeEach(() => { uuid: 'instance-uuid', }; - coreContext = { coreId, env, logger, configService: configService as any }; + coreContext = { coreId, env, logger, configService: configService as any, dynamicConfigService }; }); afterEach(() => { diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 3bd8d711c011..6d0457b2b6de 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -77,7 +77,13 @@ describe('createPluginInitializerContext', () => { const config$ = rawConfigServiceMock.create({ rawConfig: {} }); server = new Server(config$, env, logger); await server.setupCoreConfig(); - coreContext = { coreId, env, logger, configService: server.configService }; + coreContext = { + coreId, + env, + logger, + configService: server.configService, + dynamicConfigService: server.dynamicConfigService, + }; }); it('should return a globalConfig handler in the context', async () => { diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index c0eb5b29bb63..d9e79ec51a2b 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -221,6 +221,11 @@ export function createPluginSetupContext( getStartServices: () => plugin.startDependencies, auditTrail: deps.auditTrail, security: deps.security, + dynamicConfigService: { + registerDynamicConfigClientFactory: deps.dynamicConfig.registerDynamicConfigClientFactory, + registerAsyncLocalStoreRequestHeader: deps.dynamicConfig.registerAsyncLocalStoreRequestHeader, + getStartService: deps.dynamicConfig.getStartService, + }, }; } @@ -272,5 +277,10 @@ export function createPluginStartContext( auditTrail: deps.auditTrail, coreUsageData: deps.coreUsageData, crossCompatibility: deps.crossCompatibility, + dynamicConfig: { + getAsyncLocalStore: deps.dynamicConfig.getAsyncLocalStore, + getClient: deps.dynamicConfig.getClient, + createStoreFromRequest: deps.dynamicConfig.createStoreFromRequest, + }, }; } diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 06b12643a64a..502e882dc28e 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -48,6 +48,7 @@ import { PluginsSystem } from './plugins_system'; import { config } from './plugins_config'; import { take } from 'rxjs/operators'; import { DiscoveredPlugin, CompatibleEnginePluginVersions } from './types'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; const { join } = posix; const MockPluginsSystem: jest.Mock = PluginsSystem as any; @@ -60,6 +61,7 @@ let env: Env; let mockPluginSystem: jest.Mocked; let environmentSetup: ReturnType; +const dynamicConfigService = dynamicConfigServiceMock.create(); const setupDeps = coreMock.createInternalSetup(); const logger = loggingSystemMock.create(); @@ -140,7 +142,13 @@ describe('PluginsService', () => { const rawConfigService = rawConfigServiceMock.create({ rawConfig$: config$ }); configService = new ConfigService(rawConfigService, env, logger); await configService.setSchema(config.path, config.schema); - pluginsService = new PluginsService({ coreId, env, logger, configService }); + pluginsService = new PluginsService({ + coreId, + env, + logger, + configService, + dynamicConfigService, + }); [mockPluginSystem] = MockPluginsSystem.mock.instances as any; mockPluginSystem.uiPlugins.mockReturnValue(new Map()); @@ -411,7 +419,7 @@ describe('PluginsService', () => { resolve(process.cwd(), '..', 'opensearch-dashboards-extra'), ], }, - { coreId, env, logger, configService }, + { coreId, env, logger, configService, dynamicConfigService }, { uuid: 'uuid' } ); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 2f4a3dfbc07c..81a13aad678f 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -246,6 +246,10 @@ export class PluginsService implements CoreService { env = Env.createDefault(REPO_ROOT, getEnvOptions()); - coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; + coreContext = { + coreId: Symbol(), + env, + logger, + configService: configService as any, + dynamicConfigService, + }; pluginsSystem = new PluginsSystem(coreContext); }); diff --git a/src/core/server/rendering/__mocks__/params.ts b/src/core/server/rendering/__mocks__/params.ts index 77a081f01a31..146713ed99e3 100644 --- a/src/core/server/rendering/__mocks__/params.ts +++ b/src/core/server/rendering/__mocks__/params.ts @@ -32,15 +32,18 @@ import { mockCoreContext } from '../../core_context.mock'; import { httpServiceMock } from '../../http/http_service.mock'; import { pluginServiceMock } from '../../plugins/plugins_service.mock'; import { statusServiceMock } from '../../status/status_service.mock'; +import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; const context = mockCoreContext.create(); const http = httpServiceMock.createInternalSetupContract(); const uiPlugins = pluginServiceMock.createUiPlugins(); const status = statusServiceMock.createInternalSetupContract(); +const dynamicConfig = dynamicConfigServiceMock.createInternalSetupContract(); export const mockRenderingServiceParams = context; export const mockRenderingSetupDeps = { http, uiPlugins, status, + dynamicConfig, }; diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index fb93d63180d2..371737929f31 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -66,6 +66,7 @@ export class RenderingService { http, status, uiPlugins, + dynamicConfig, }: RenderingSetupDeps): Promise { const [opensearchDashboardsConfig, serverConfig] = await Promise.all([ this.coreContext.configService @@ -99,6 +100,8 @@ export class RenderingService { opensearchDashboardsConfig as OpenSearchDashboardsConfigType ); + const dynamicConfigStartServices = await dynamicConfig.getStartService(); + const metadata: RenderingMetadata = { strictCsp: http.csp.strict, uiPublicUrl, @@ -123,7 +126,15 @@ export class RenderingService { [...uiPlugins.public].map(async ([id, plugin]) => ({ id, plugin, - config: await this.getUiConfig(uiPlugins, id), + // TODO Scope the client so that only exposedToBrowser configs are exposed + config: this.coreContext.dynamicConfigService.hasDefaultConfigs({ name: id }) + ? await dynamicConfigStartServices.getClient().getConfig( + { name: id }, + { + asyncLocalStorageContext: dynamicConfigStartServices.getAsyncLocalStore()!, + } + ) + : await this.getUiConfig(uiPlugins, id), })) ), legacyMetadata: { diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index ecf3e2a9674e..0e0d63b67c79 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -31,7 +31,7 @@ import { i18n } from '@osd/i18n'; import { Branding } from 'src/core/types'; -import { EnvironmentMode, PackageInfo } from '../config'; +import { EnvironmentMode, InternalDynamicConfigServiceSetup, PackageInfo } from '../config'; import { ICspConfig } from '../csp'; import { InternalHttpServiceSetup, OpenSearchDashboardsRequest, LegacyRequest } from '../http'; import { UiPlugins, DiscoveredPlugin } from '../plugins'; @@ -83,6 +83,7 @@ export interface RenderingSetupDeps { http: InternalHttpServiceSetup; status: InternalStatusServiceSetup; uiPlugins: UiPlugins; + dynamicConfig: InternalDynamicConfigServiceSetup; } /** @public */ diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts index 6fcd62372d8d..f082d1a743e4 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerBulkCreateRoute } from '../bulk_create'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -50,7 +51,8 @@ describe('POST /api/saved_objects/_bulk_create', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerBulkCreateRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts index e3fb8dc3e823..4fd6aabe188c 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerBulkGetRoute } from '../bulk_get'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -52,7 +53,8 @@ describe('POST /api/saved_objects/_bulk_get', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerBulkGetRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts index 1af732e39d1b..b21999cb0091 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerBulkUpdateRoute } from '../bulk_update'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -49,7 +50,8 @@ describe('PUT /api/saved_objects/_bulk_update', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerBulkUpdateRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/create.test.ts b/src/core/server/saved_objects/routes/integration_tests/create.test.ts index 6348a6f7901d..5272b038b279 100644 --- a/src/core/server/saved_objects/routes/integration_tests/create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/create.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerCreateRoute } from '../create'; import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -59,7 +60,8 @@ describe('POST /api/saved_objects/{type}', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerCreateRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index 6f8da59348e9..c4c23cb6e74d 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerDeleteRoute } from '../delete'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -49,7 +50,8 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerDeleteRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index 1b40a1942457..a313aa505e25 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -28,6 +28,8 @@ * under the License. */ +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; + jest.mock('../../export', () => ({ exportSavedObjectsToStream: jest.fn(), })); @@ -62,7 +64,8 @@ describe('POST /api/saved_objects/_export', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerExportRoute(router, config); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index fc21eefed434..292f22cde1b3 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -35,6 +35,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerFindRoute } from '../find'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -60,7 +61,8 @@ describe('GET /api/saved_objects/_find', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerFindRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/get.test.ts b/src/core/server/saved_objects/routes/integration_tests/get.test.ts index 9b41c0b61005..167adc9b73ed 100644 --- a/src/core/server/saved_objects/routes/integration_tests/get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/get.test.ts @@ -35,6 +35,7 @@ import { savedObjectsClientMock } from '../../service/saved_objects_client.mock' import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { createHttpServer, createCoreContext } from '../../../http/test_utils'; import { coreMock } from '../../../mocks'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; const coreId = Symbol('core'); @@ -63,7 +64,8 @@ describe('GET /api/saved_objects/{type}/{id}', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerGetRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 1c51234173ae..fde41390b505 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -36,6 +36,7 @@ import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { SavedObjectConfig } from '../../saved_objects_config'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectsErrorHelpers } from '../..'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -84,7 +85,8 @@ describe(`POST ${URL}`, () => { const router = httpSetup.createRouter('/internal/saved_objects/'); registerImportRoute(router, config); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts index d79eae3e69b6..7f0ce4e20bd2 100644 --- a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerLogLegacyImportRoute } from '../log_legacy_import'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -48,7 +49,8 @@ describe('POST /api/saved_objects/_log_legacy_import', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerLogLegacyImportRoute(router, logger); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index c8559133f9cd..4fa22c85f794 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -35,6 +35,7 @@ import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectConfig } from '../../saved_objects_config'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -89,7 +90,8 @@ describe(`POST ${URL}`, () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerResolveImportErrorsRoute(router, config); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/update.test.ts b/src/core/server/saved_objects/routes/integration_tests/update.test.ts index 6b6c1795bbbf..7619649ddc8e 100644 --- a/src/core/server/saved_objects/routes/integration_tests/update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/update.test.ts @@ -33,6 +33,7 @@ import { UnwrapPromise } from '@osd/utility-types'; import { registerUpdateRoute } from '../update'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { setupServer } from '../test_utils'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; type SetupServerReturn = UnwrapPromise>; @@ -60,7 +61,8 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { const router = httpSetup.createRouter('/api/saved_objects/'); registerUpdateRoute(router); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }); afterEach(async () => { diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index ef93122bc6c7..bb44485cf61a 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -117,3 +117,9 @@ export const mockAuditTrailService = auditTrailServiceMock.create(); jest.doMock('./audit_trail/audit_trail_service', () => ({ AuditTrailService: jest.fn(() => mockAuditTrailService), })); + +import { dynamicConfigServiceMock } from './config/dynamic_config_service.mock'; +export const mockDynamicConfigService = dynamicConfigServiceMock.create(); +jest.doMock('./config/dynamic_config_service', () => ({ + DynamicConfigService: jest.fn(() => mockDynamicConfigService), +})); diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 71789d76c337..87739a4aa3f6 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -43,6 +43,7 @@ import { mockStatusService, mockLoggingService, mockAuditTrailService, + mockDynamicConfigService, } from './server.test.mocks'; import { BehaviorSubject } from 'rxjs'; @@ -84,6 +85,7 @@ test('sets up services on "setup"', async () => { expect(mockStatusService.setup).not.toHaveBeenCalled(); expect(mockLoggingService.setup).not.toHaveBeenCalled(); expect(mockAuditTrailService.setup).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.setup).not.toHaveBeenCalled(); await server.setup(); @@ -98,6 +100,7 @@ test('sets up services on "setup"', async () => { expect(mockStatusService.setup).toHaveBeenCalledTimes(1); expect(mockLoggingService.setup).toHaveBeenCalledTimes(1); expect(mockAuditTrailService.setup).toHaveBeenCalledTimes(1); + expect(mockDynamicConfigService.setup).toHaveBeenCalledTimes(1); }); test('injects legacy dependency to context#setup()', async () => { @@ -140,6 +143,7 @@ test('runs services on "start"', async () => { expect(mockUiSettingsService.start).not.toHaveBeenCalled(); expect(mockMetricsService.start).not.toHaveBeenCalled(); expect(mockAuditTrailService.start).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.start).not.toHaveBeenCalled(); await server.start(); @@ -149,6 +153,7 @@ test('runs services on "start"', async () => { expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1); expect(mockMetricsService.start).toHaveBeenCalledTimes(1); expect(mockAuditTrailService.start).toHaveBeenCalledTimes(1); + expect(mockDynamicConfigService.start).toHaveBeenCalledTimes(1); }); test('does not fail on "setup" if there are unused paths detected', async () => { @@ -174,6 +179,7 @@ test('stops services on "stop"', async () => { expect(mockStatusService.stop).not.toHaveBeenCalled(); expect(mockLoggingService.stop).not.toHaveBeenCalled(); expect(mockAuditTrailService.stop).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.stop).not.toHaveBeenCalled(); await server.stop(); @@ -205,6 +211,7 @@ test(`doesn't setup core services if config validation fails`, async () => { expect(mockMetricsService.setup).not.toHaveBeenCalled(); expect(mockStatusService.setup).not.toHaveBeenCalled(); expect(mockLoggingService.setup).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.setup).not.toHaveBeenCalled(); }); test(`doesn't setup core services if legacy config validation fails`, async () => { @@ -227,4 +234,5 @@ test(`doesn't setup core services if legacy config validation fails`, async () = expect(mockMetricsService.setup).not.toHaveBeenCalled(); expect(mockStatusService.setup).not.toHaveBeenCalled(); expect(mockLoggingService.setup).not.toHaveBeenCalled(); + expect(mockDynamicConfigService.setup).not.toHaveBeenCalled(); }); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index b76ec61cebd0..1b2e5f05679e 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -64,17 +64,20 @@ import { config as opensearchDashboardsConfig } from './opensearch_dashboards_co import { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects'; import { config as uiSettingsConfig } from './ui_settings'; import { config as statusConfig } from './status'; +import { config as dynamicConfigServiceConfig } from './config'; import { ContextService } from './context'; import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; +import { DynamicConfigService } from './config/dynamic_config_service'; const coreId = Symbol('core'); const rootConfigPath = ''; export class Server { public readonly configService: ConfigService; + public readonly dynamicConfigService: DynamicConfigService; private readonly capabilities: CapabilitiesService; private readonly context: ContextService; private readonly opensearch: OpenSearchService; @@ -110,8 +113,15 @@ export class Server { this.logger = this.loggingSystem.asLoggerFactory(); this.log = this.logger.get('server'); this.configService = new ConfigService(rawConfigProvider, env, this.logger); + this.dynamicConfigService = new DynamicConfigService(this.configService, env, this.logger); - const core = { coreId, configService: this.configService, env, logger: this.logger }; + const core = { + coreId, + configService: this.configService, + dynamicConfigService: this.dynamicConfigService, + env, + logger: this.logger, + }; this.context = new ContextService(core); this.http = new HttpService(core); this.rendering = new RenderingService(core); @@ -151,6 +161,9 @@ export class Server { await this.configService.validate(); await ensureValidConfiguration(this.configService, legacyConfigSetup); + // Once the configs have been validated, setup the dynamic config as schemas have also been verified + const dynamicConfigServiceSetup = await this.dynamicConfigService.setup(); + const contextServiceSetup = this.context.setup({ // We inject a fake "legacy plugin" with dependencies on every plugin so that legacy plugins: // 1) Can access context from any KP plugin @@ -168,6 +181,9 @@ export class Server { context: contextServiceSetup, }); + // Once http is setup, register routes and async local storage + await this.dynamicConfigService.registerRoutesAndHandlers({ http: httpSetup }); + const capabilitiesSetup = this.capabilities.setup({ http: httpSetup }); const opensearchServiceSetup = await this.opensearch.setup({ @@ -199,6 +215,7 @@ export class Server { http: httpSetup, status: statusSetup, uiPlugins, + dynamicConfig: dynamicConfigServiceSetup, }); const httpResourcesSetup = this.httpResources.setup({ @@ -229,6 +246,7 @@ export class Server { logging: loggingSetup, metrics: metricsSetup, security: securitySetup, + dynamicConfig: dynamicConfigServiceSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -256,6 +274,9 @@ export class Server { const opensearchStart = await this.opensearch.start({ auditTrail: auditTrailStart, }); + const dynamicConfigServiceStart = await this.dynamicConfigService.start({ + opensearch: opensearchStart, + }); const soStartSpan = startTransaction?.startSpan('saved_objects.migration', 'migration'); const savedObjectsStart = await this.savedObjects.start({ opensearch: opensearchStart, @@ -286,6 +307,7 @@ export class Server { auditTrail: auditTrailStart, coreUsageData: coreUsageDataStart, crossCompatibility: crossCompatibilityServiceStart, + dynamicConfig: dynamicConfigServiceStart, }; const pluginsStart = await this.plugins.start(this.coreStart); @@ -298,7 +320,7 @@ export class Server { plugins: mapToObject(pluginsStart.contracts), }); - await this.http.start(); + await this.http.start({ dynamicConfigService: dynamicConfigServiceStart }); await this.security.start(); @@ -349,6 +371,7 @@ export class Server { opsConfig, statusConfig, pidConfig, + dynamicConfigServiceConfig, ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); @@ -357,6 +380,7 @@ export class Server { this.configService.addDeprecationProvider(descriptor.path, descriptor.deprecations); } await this.configService.setSchema(descriptor.path, descriptor.schema); + this.dynamicConfigService.setSchema(descriptor.path, descriptor.schema); } } } diff --git a/src/core/server/status/routes/integration_tests/status.test.ts b/src/core/server/status/routes/integration_tests/status.test.ts index 1045ca939c05..f01bc748a289 100644 --- a/src/core/server/status/routes/integration_tests/status.test.ts +++ b/src/core/server/status/routes/integration_tests/status.test.ts @@ -42,6 +42,7 @@ import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { registerStatusRoute } from '../status'; import { ServiceStatus, ServiceStatusLevels } from '../../types'; import { statusServiceMock } from '../../status_service.mock'; +import { dynamicConfigServiceMock } from '../../../config/dynamic_config_service.mock'; const coreId = Symbol('core'); @@ -100,7 +101,8 @@ describe('GET /api/status', () => { } }); - await server.start(); + const dynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); + await server.start({ dynamicConfigService }); }; afterEach(async () => { diff --git a/src/core/test_helpers/osd_server.ts b/src/core/test_helpers/osd_server.ts index 8f03ac949842..ff98a49f6999 100644 --- a/src/core/test_helpers/osd_server.ts +++ b/src/core/test_helpers/osd_server.ts @@ -61,6 +61,7 @@ const DEFAULTS_SETTINGS = { logging: { silent: true }, plugins: {}, migrations: { skip: true }, + dynamic_config_service: { enabled: false }, }; const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { diff --git a/src/plugins/csp_handler/server/plugin.ts b/src/plugins/csp_handler/server/plugin.ts index 9f4094262452..46dc8ef544cf 100644 --- a/src/plugins/csp_handler/server/plugin.ts +++ b/src/plugins/csp_handler/server/plugin.ts @@ -22,14 +22,17 @@ export class CspHandlerPlugin implements Plugin void; + flyoutHeader: JSX.Element; + flyoutBody: JSX.Element; + flyoutFooter: JSX.Element; + ariaLabel: string; + size?: string; +} + +export const FlyoutContainers = ({ + closeFlyout, + flyoutHeader, + flyoutBody, + flyoutFooter, + ariaLabel, + size, +}: Props) => { + return ( +
+ closeFlyout()} + size={size ? size : 'm'} + aria-labelledby={ariaLabel} + > + {flyoutHeader} + {flyoutBody} + {flyoutFooter} + +
+ ); +}; diff --git a/src/plugins/data/public/query/query_string/language_service/get_query_control_links.tsx b/src/plugins/data/public/query/query_string/language_service/get_query_control_links.tsx new file mode 100644 index 000000000000..9b92d22ae432 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/get_query_control_links.tsx @@ -0,0 +1,172 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPopoverTitle, + EuiText, + EuiWrappingPopover, +} from '@elastic/eui'; +import ReactDOM from 'react-dom'; +import { FormattedMessage } from 'react-intl'; +import { + OpenSearchDashboardsContextProvider, + toMountPoint, +} from '../../../../../opensearch_dashboards_react/public'; +import { IDataPluginServices } from '../../../types'; +import { PPLReferenceFlyout } from './ppl_reference_flyout'; + +export interface QueryControl { + id: string; + label: string; + testId: string; + ariaLabel: string; + run: (anchorElement: HTMLElement) => void; + iconType: string; +} + +export const QueryControls = (props: { + services: IDataPluginServices; + queryLanguage: string; + onToggleCollapse: () => void; + savedQueryManagement?: any; + additionalControls?: QueryControl[]; +}) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const [isLanguageReferenceOpen, setIsLanguageReferenceOpen] = useState(false); + + const languageReferenceContainer = document.createElement('div'); + + const onCloseLanguageReference = () => { + ReactDOM.unmountComponentAtNode(languageReferenceContainer); + setIsLanguageReferenceOpen(false); + }; + + const osdDQLDocs = 'https://opensearch.org/docs/2.16/dashboards/dql)'; + const dqlFullName = ( + + ); + + const languageReference: QueryControl = { + id: 'languageReference', + label: i18n.translate('discover.queryControls.languageReference', { + defaultMessage: 'Open', + }), + testId: 'languageReference', + ariaLabel: i18n.translate('discover.queryControls.languageReference', { + defaultMessage: `Language Reference`, + }), + run: async (anchorElement) => { + if (props.queryLanguage === 'PPL' || props.queryLanguage === 'SQL') { + const flyoutSession = props.services.overlays!.openFlyout( + toMountPoint( + + flyoutSession?.close?.().then()} + makeUrl={(searchId: any) => `#/view/${encodeURIComponent(searchId)}`} + /> + + ) + ); + } else { + if (isLanguageReferenceOpen) { + onCloseLanguageReference(); + return; + } + + setIsLanguageReferenceOpen(true); + document.body.appendChild(languageReferenceContainer); + + const element = ( + + + + +
+ +

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

+
+
+
+ ); + + ReactDOM.render(element, languageReferenceContainer); + } + }, + iconType: 'iInCircle', + }; + + const languageToggle: QueryControl = { + id: 'languageToggle', + label: i18n.translate('discover.queryControls.languageToggle', { + defaultMessage: 'Toggle', + }), + testId: 'languageToggle', + ariaLabel: i18n.translate('discover.queryControls.languageToggle', { + defaultMessage: `Language Toggle`, + }), + run: () => { + setIsCollapsed(!isCollapsed); + props.onToggleCollapse(); + }, + iconType: isCollapsed ? 'expand' : 'minimize', + }; + + const queryControls = + props.queryLanguage === 'PPL' || props.queryLanguage === 'SQL' + ? [languageReference, languageToggle] + : [languageReference]; + + if (props.additionalControls) { + queryControls.push(...props.additionalControls); + } + + return ( + + {queryControls.map((queryControl) => ( + + queryControl.run(event.currentTarget)} + /> + + ))} + {props.savedQueryManagement} + + ); +}; diff --git a/src/plugins/data/public/query/query_string/language_service/language_service.ts b/src/plugins/data/public/query/query_string/language_service/language_service.ts index 328912a78666..923838c26bb2 100644 --- a/src/plugins/data/public/query/query_string/language_service/language_service.ts +++ b/src/plugins/data/public/query/query_string/language_service/language_service.ts @@ -27,6 +27,10 @@ export class LanguageService { this.queryEditorExtensionMap = {}; } + public createDefaultQueryEditor() { + return createEditor(SingleLineInput, SingleLineInput, DQLBody); + } + public __enhance = (enhancements: UiEnhancements) => { if (enhancements.queryEditorExtension) { this.queryEditorExtensionMap[enhancements.queryEditorExtension.id] = diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/dedup.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/dedup.ts new file mode 100644 index 000000000000..1acde2fb90c9 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/dedup.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const dedupCmd = `## dedup +--- + +### Description + +Use the \'dedup\' command to remove identical documents from the search results, based on the specified field. + +### Syntax + +dedup \[int\] <field-list> \[keepempty=<bool>\] +\[consecutive=<bool>\] + +- \`field-list\`: Required. The comma-delimited field list. At least one field is required. +- \`consecutive\`: Optional. If set to \`true\`, removes duplicate events, where the duplicate events have consecutive timestamps. Default is \`false\`. +- \`int\`: Optional. The \'dedup\' command retains multiple events for each combination when you specify \`<int>\`. The number for \`<int>\` must be greater than 0. If you do not specify a number, only the first occurring event is kept. All other duplicates are removed from the results. Default is \`1\`. +- \`keepempty\`: Optional. If set to \`true\`, keeps the document if any field in the \`field-list\` is null or missing. Default is \`false\`. + +#### Example 1: Dedup by one field + +The following example PPL query shows how to use \`dedup\` to remove duplicate documents based on the \`gender\` field: + + os> source=accounts | dedup gender | fields account_number, gender; + fetched rows / total rows = 2/2 + +------------------+----------+ + | account_number | gender | + |------------------+----------| + | 1 | M | + | 13 | F | + +------------------+----------+ + +#### Example 2: Keep two duplicate documents + +The following example PPL query shows how to use \`dedup\` to remove duplicate documents based on the \`gender\` field while keeping two duplicates: + + os> source=accounts | dedup 2 gender | fields account_number, gender; + fetched rows / total rows = 3/3 + +------------------+----------+ + | account_number | gender | + |------------------+----------| + | 1 | M | + | 6 | M | + | 13 | F | + +------------------+----------+ + +#### Example 3: Keep or ignore empty fields by default + +The following example PPL query shows how to use \`dedup\` to remove duplicate documents while keeping documents with null values in the specified field: + + os> source=accounts | dedup email keepempty=true | fields account_number, email; + fetched rows / total rows = 4/4 + +------------------+-----------------------+ + | account_number | email | + |------------------+-----------------------| + | 1 | amberduke@pyrami.com | + | 6 | hattiebond@netagy.com | + | 13 | null | + | 18 | daleadams@boink.com | + +------------------+-----------------------+ + +The following example PPL query shows how to use \`dedup\` to remove duplicate documents while ignoring documents with empty values in the specified field: + + os> source=accounts | dedup email | fields account_number, email; + fetched rows / total rows = 3/3 + +------------------+-----------------------+ + | account_number | email | + |------------------+-----------------------| + | 1 | amberduke@pyrami.com | + | 6 | hattiebond@netagy.com | + | 18 | daleadams@boink.com | + +------------------+-----------------------+ + +#### Example 4: Remove duplicate consecutive documents + +The following example PPL query shows how to use \`dedup\` to remove duplicate consecutive documents: + + os> source=accounts | dedup gender consecutive=true | fields account_number, gender; + fetched rows / total rows = 3/3 + +------------------+----------+ + | account_number | gender | + |------------------+----------| + | 1 | M | + | 13 | F | + | 18 | M | + +------------------+----------+ + +### Limitation +The \`dedup\` command is not rewritten to [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/). It is only run on the coordinating node. +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/eval.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/eval.ts new file mode 100644 index 000000000000..f20a5ee35198 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/eval.ts @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const evalCmd = `## eval +--- + +### Description + +Use the \'eval\' command to evaluate the expression and append the result to the search result. + +### Syntax + +eval <field>=<expression> \["," +<field>=<expression> \]... + +- \`field\`: Required. If the field name does not exist, a new field is created. If the field name exists, the value of the existing field is replaced. +- \`expression\`: Required. Any expression that is supported by the system. + +#### Example 1: Create new fields + +The following example PPL query shows how to use \`eval\` to create a new field for each document. In this example, the new field is \`doubleAge\`. + + os> source=accounts | eval doubleAge = age * 2 | fields age, doubleAge; + fetched rows / total rows = 4/4 + +-------+-------------+ + | age | doubleAge | + |-------+-------------| + | 32 | 64 | + | 36 | 72 | + | 28 | 56 | + | 33 | 66 | + +-------+-------------+ + +#### Example 2: Override existing fields + +The following example PPL query shows how to use \`eval\` to override an existing field. In this example, the existing field \`age\` is overridden by the \`age\` field plus 1. + + os> source=accounts | eval age = age + 1 | fields age; + fetched rows / total rows = 4/4 + +-------+ + | age | + |-------| + | 33 | + | 37 | + | 29 | + | 34 | + +-------+ + +#### Example 3: Create new fields based on the fields defined in the \`eval\` expression + +The following example PPL query shows how to use \`eval\` to create a new field based on the fields defined in the \`eval\` expression. In this example, the new field \`ddAge\` is the evaluation result of the \`doubleAge\` field multiplied by 2. \`doubleAge\` is defined in the \`eval\` command. + + os> source=accounts | eval doubleAge = age * 2, ddAge = doubleAge * 2 | fields age, doubleAge, ddAge; + fetched rows / total rows = 4/4 + +-------+-------------+---------+ + | age | doubleAge | ddAge | + |-------+-------------+---------| + | 32 | 64 | 128 | + | 36 | 72 | 144 | + | 28 | 56 | 112 | + | 33 | 66 | 132 | + +-------+-------------+---------+ + +### Limitation +The \`eval\` command is not rewritten to [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/). It is only run on the coordinating node. +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/fields.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/fields.ts new file mode 100644 index 000000000000..2110349b54eb --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/fields.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const fieldsCmd = `## fields +--- +### Description + +Use the \`fields\` command to specify the fields that should be included in or excluded from the search results. + +### Syntax + +fields \[+\|-\] <field-list> + +- \`field-list\`: Required. Comma-separated list of fields to keep or remove. +- \`index\`: Optional. If the plus sign \`+\` is used, only the fields specified in the field list will be included. If the minus \`-\` is used, all the fields specified in the field list will be excluded. Default is \`+\`. + +#### Example 1: Select specified fields from the search result + +The following example PPL query shows how to retrieve the \`account\_number\`, \`firstname\`, and \`lastname\` fields from the search results: + + os> source=accounts | fields account_number, firstname, lastname; + fetched rows / total rows = 4/4 + +------------------+-------------+------------+ + | account_number | firstname | lastname | + |------------------+-------------+------------| + | 1 | Amber | Duke | + | 6 | Hattie | Bond | + | 13 | Nanette | Bates | + | 18 | Dale | Adams | + +------------------+-------------+------------+ + +#### Example 2: Remove specified fields from the search results + +The following example PPL query shows how to remove the \`account\_number\` field from the search results: + + os> source=accounts | fields account_number, firstname, lastname | fields - account_number; + fetched rows / total rows = 4/4 + +-------------+------------+ + | firstname | lastname | + |-------------+------------| + | Amber | Duke | + | Hattie | Bond | + | Nanette | Bates | + | Dale | Adams | + +-------------+------------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/head.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/head.ts new file mode 100644 index 000000000000..468da72d3871 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/head.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const headCmd = `## head +--- + +### Description + +Use the \`head\` command to return the first N number of lines from a search result. + +### Syntax + +head \[N\] + +- \`N\`: Optional. The number of results you want to return. Default is 10. + +#### Example 1: Get the first 10 results + +The following example PPL query shows how to use \`head\` to return the first 10 search results: + + os> source=accounts | fields firstname, age | head; + fetched rows / total rows = 10/10 + +---------------+-----------+ + | firstname | age | + |---------------+-----------| + | Amber | 32 | + | Hattie | 36 | + | Nanette | 28 | + | Dale | 33 | + | Elinor | 36 | + | Virginia | 39 | + | Dillard | 34 | + | Mcgee | 39 | + | Aurelia | 37 | + | Fulton | 23 | + +---------------+-----------+ + +#### Example 2: Get the first N results + +The following example PPL query shows how to use \`head\` to get a specified number of search results. In this example, N is equal to 3: + + os> source=accounts | fields firstname, age | head 3; + fetched rows / total rows = 3/3 + +---------------+-----------+ + | firstname | age | + |---------------+-----------| + | Amber | 32 | + | Hattie | 36 | + | Nanette | 28 | + +---------------+-----------+ + +#### Limitation +The \`head\` command is not rewritten to [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/). It is only run on the coordinating node. +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/index.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/index.ts new file mode 100644 index 000000000000..27328a4c2be8 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { dedupCmd } from './dedup'; +export { evalCmd } from './eval'; +export { fieldsCmd } from './fields'; +export { headCmd } from './head'; +export { parseCmd } from './parse'; +export { rareCmd } from './rare'; +export { renameCmd } from './rename'; +export { searchCmd } from './search'; +export { sortCmd } from './sort'; +export { statsCmd } from './stats'; +export { syntaxCmd } from './syntax'; +export { topCmd } from './top'; +export { whereCmd } from './where'; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/parse.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/parse.ts new file mode 100644 index 000000000000..28501cf8555e --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/parse.ts @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const parseCmd = `## parse +--- +### Description + +Use the \`parse\` command to extract information from a text field using a regular expression and add it to the search result. + +### Syntax + +parse <field> <regular-expression> + +- \`field\`: Required. Must be a text field. +- \`regular-expression\`: Required. The regular expression used to extract new fields from a text field. It replaces the original field if a new field name exists. + +### Regular expression + +Use the Java regular expression engine to match the entire text field of each document. Each named capture group in the expression will be converted to a new \`string\` field. + +#### Example 1: Create a new field + +The following example PPL query shows how to create new field \`host\` for each document. \`host\` becomes the hostname after the @ symbol in the \`email\` field. Parsing a null field returns an empty string. + + os> source=accounts | parse email '.+@(?.+)' | fields email, host; + fetched rows / total rows = 4/4 + +-----------------------+------------+ + | email | host | + |-----------------------+------------| + | amberduke@pyrami.com | pyrami.com | + | hattiebond@netagy.com | netagy.com | + | null | | + | daleadams@boink.com | boink.com | + +-----------------------+------------+ + +#### Example 2: Override an existing field + +The following example PPL query shows how to override the existing \`address\` field while excluding the street number: + + os> source=accounts | parse address '\\d+ (?
.+)' | fields address; + fetched rows / total rows = 4/4 + +------------------+ + | address | + |------------------| + | Holmes Lane | + | Bristol Street | + | Madison Street | + | Hutchinson Court | + +------------------+ + +#### Example 3: Filter and sort by casted-parsed field + +The following example PPL query shows how to sort street numbers that are greater than 500 in the \`address\` field: + + os> source=accounts | parse address '(?\d+) (?.+)' | where cast(streetNumber as int) > 500 | sort num(streetNumber) | fields streetNumber, street; + fetched rows / total rows = 3/3 + +----------------+----------------+ + | streetNumber | street | + |----------------+----------------| + | 671 | Bristol Street | + | 789 | Madison Street | + | 880 | Holmes Lane | + +----------------+----------------+ + +### Limitation + +The following limitations apply: + +- Parsed fields cannot be parsed again. For example, the following command is not valid: + + source=accounts | parse address '\\d+ (?.+)' | parse street '\\w+ (?\\w+)'; + +- Other commands cannot overwrite fields created by parsing. For example, in the following query, \`where\` does not match any documents because \`street\` cannot be overridden: + + source=accounts | parse address '\\d+ (?.+)' | eval street='1' | where street='1'; + +- The text field that is parsed cannot be overridden. For example, in the following query, \`street\` is not successfully parsed because \`address\` is overridden: + + source=accounts | parse address '\\d+ (?.+)' | eval address='1'; + +- Fields created by parsing cannot be filtered or sorted after using them in the \`stats\` command. For example, in the following query, \`where\` is not valid: + + source=accounts | parse email '.+@(?.+)' | stats avg(age) by host | where host=pyrami.com; +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/rare.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/rare.ts new file mode 100644 index 000000000000..61f5016f4e9e --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/rare.ts @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const rareCmd = `## rare +--- + +### Description + +Use the \`rare\` command to find the least common tuple of values across all fields in the \`field-list\` field. A maximum of 10 results is returned for each distinct tuple of group-by field values. + +### Syntax + +rare <field-list> \[by-clause\] + +- \`field-list\`: Required. A comma-separated list of field names. +- \`by-clause\`: Optional. One or more fields to group by. + +#### Example 1: Find a field's least common values + +The following example PPL query shows how to find a least common value in the \`gender\` field: + + os> source=accounts | rare gender; + fetched rows / total rows = 2/2 + +------------+ + | gender | + |------------| + | F | + |------------| + | M | + +------------+ + +#### Example 2: Find least common values in group-by fields + +The following example PPL query shows how to find a least common value in the \`age\` field that is grouped by \`gender\`: + + os> source=accounts | rare age by gender; + fetched rows / total rows = 20/20 + +----------+----------+ + | gender | age | + |----------+----------| + | F | 29 | + | F | 20 | + | F | 23 | + | F | 25 | + | F | 37 | + | F | 38 | + | F | 40 | + | F | 27 | + | F | 36 | + | F | 24 | + | M | 27 | + | M | 24 | + | M | 34 | + | M | 38 | + | M | 28 | + | M | 39 | + | M | 21 | + | M | 30 | + | M | 25 | + | M | 29 | + +----------+----------+ + +#### Limitation +The \`rare\` command is not rewritten to [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/). It is only run on the coordinating node. +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/rename.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/rename.ts new file mode 100644 index 000000000000..22d9790514a8 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/rename.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const renameCmd = `## rename +--- +### Description + +Use the \`rename\` command to rename one or more fields in the search result. + +### Syntax + +rename <source-field> AS <target-field>\["," +<source-field> AS <target-field>\]... + +- \`source-field\`: Required. The field to rename. +- \`target-field\`: Required. The new field. + +#### Example 1: Rename one field + +The following example PPL query renames a field: + + os> source=accounts | rename account_number as an | fields an; + fetched rows / total rows = 4/4 + +------+ + | an | + |------| + | 1 | + | 6 | + | 13 | + | 18 | + +------+ + +#### Example 2: Rename two or more fields + +The following example PPL query renames two or more fields: + + os> source=accounts | rename account_number as an, employer as emp | fields an, emp; + fetched rows / total rows = 4/4 + +------+---------+ + | an | emp | + |------+---------| + | 1 | Pyrami | + | 6 | Netagy | + | 13 | Quility | + | 18 | null | + +------+---------+ + +#### Limitation +The \`rename\` command is not rewritten to [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/). It is only run on the coordinating node. +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/search.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/search.ts new file mode 100644 index 000000000000..2c2e361dd958 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/search.ts @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const searchCmd = `## search +--- + +### Description + +Use the \`search\` command to retrieve a document from the index. The \`search\` +command can only be used as the first command in a PPL query. + +### Syntax + +search source=<index> \[boolean-expression\] + +- \`search\`: Search keywords, which can be ignored. +- \`index\`: Required. Search commands must specify the index to query. +- \`bool-expression\`: Optional. Any expression that can be evaluated to a Boolean value. + +#### Example 1: Fetch all data from an index + +The following example PPL query shows how to fetch all documents from the \`accounts\` index: + + os> source=accounts; + fetched rows / total rows = 4/4 + +----------------+-----------+----------------------+---------+--------+--------+----------+-------+-----+-----------------------+----------+ + | account_number | firstname | address | balance | gender | city | employer | state | age | email | lastname | + +----------------+-----------+----------------------+---------+--------+--------+----------+-------+-----+-----------------------+----------+ + | 1 | Amber | 880 Holmes Lane | 39225 | M | Brogan | Pyrami | IL | 32 | amberduke@pyrami.com | Duke | + | 6 | Hattie | 671 Bristol Street | 5686 | M | Dante | Netagy | TN | 36 | hattiebond@netagy.com | Bond | + | 13 | Nanette | 789 Madison Street | 32838 | F | Nogal | Quility | VA | 28 | null | Bates | + | 18 | Dale | 467 Hutchinson Court | 4180 | M | Orick | null | MD | 33 | daleadams@boink.com | Adams | + +----------------+-----------+----------------------+---------+--------+--------+----------+-------+-----+-----------------------+----------+ + +#### Example 2: Fetch data with a condition + +The following example PPL query shows how to fetch all documents from the \`accounts\` index by using the \`or\` condition. + + os> source=accounts account_number=1 or gender="F"; + fetched rows / total rows = 2/2 + +------------------+-------------+--------------------+-----------+----------+--------+------------+---------+-------+----------------------+------------+ + | account_number | firstname | address | balance | gender | city | employer | state | age | email | lastname | + |------------------+-------------+--------------------+-----------+----------+--------+------------+---------+-------+----------------------+------------| + | 1 | Amber | 880 Holmes Lane | 39225 | M | Brogan | Pyrami | IL | 32 | amberduke@pyrami.com | Duke | + | 13 | Nanette | 789 Madison Street | 32838 | F | Nogal | Quility | VA | 28 | null | Bates | + +------------------+-------------+--------------------+-----------+----------+--------+------------+---------+-------+----------------------+------------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/sort.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/sort.ts new file mode 100644 index 000000000000..d31e84a96e29 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/sort.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const sortCmd = `## sort +--- +### Description + +Use the \`sort\` command to sort search results by a specified field. + +### Syntax + +sort <\[+\|-\] sort-field>... + +- \`sort-field\`: Required. The field to sort by. +- \[+\|-\]: Optional. The symbols used to indicate the sorting order. A plus sign \[+\] indicates ascending order, with null and missing values first. A minus sign \[-\] indicates descending order, with null and missing last. Default is \[+\], with null and missing first. + +#### Example 1: Sort by one field + +The following example PPL query shows how to sort documents by one field in ascending order: + + os> source=accounts | sort age | fields account_number, age; + fetched rows / total rows = 4/4 + +------------------+-------+ + | account_number | age | + |------------------+-------| + | 13 | 28 | + | 1 | 32 | + | 18 | 33 | + | 6 | 36 | + +------------------+-------+ + +#### Example 2: Sort by one field and return all results + +The following example PPL query shows how to sort by one field and return all results in ascending order: + + os> source=accounts | sort age | fields account_number, age; + fetched rows / total rows = 4/4 + +------------------+-------+ + | account_number | age | + |------------------+-------| + | 13 | 28 | + | 1 | 32 | + | 18 | 33 | + | 6 | 36 | + +------------------+-------+ + +#### Example 3: Sort by one field in descending order + +The following example PPL query shows how to sort by one field in descending order: + + os> source=accounts | sort - age | fields account_number, age; + fetched rows / total rows = 4/4 + +------------------+-------+ + | account_number | age | + |------------------+-------| + | 6 | 36 | + | 18 | 33 | + | 1 | 32 | + | 13 | 28 | + +------------------+-------+ + +#### Example 4: Sort multiple fields in both ascending and descending order + +The following example PPL query shows how to sort by multiple fields in both ascending and descending order. In this example, the \`gender\` field is in ascending order and the \`age\` field is in descending order. + + os> source=accounts | sort + gender, - age | fields account_number, gender, age; + fetched rows / total rows = 4/4 + +------------------+----------+-------+ + | account_number | gender | age | + |------------------+----------+-------| + | 13 | F | 28 | + | 6 | M | 36 | + | 18 | M | 33 | + | 1 | M | 32 | + +------------------+----------+-------+ + +#### Example 5: Sort by field, including null values + +The following example PPL query shows how to sort by the \`employer\` field using the default order (\[+\] with null and missing first): + + os> source=accounts | sort employer | fields employer; + fetched rows / total rows = 4/4 + +------------+ + | employer | + |------------| + | null | + | Netagy | + | Pyrami | + | Quility | + +------------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/stats.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/stats.ts new file mode 100644 index 000000000000..5392c9ef4b5d --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/stats.ts @@ -0,0 +1,282 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const statsCmd = `## stats +--- + +### Description + +Use the \`stats\` command to calculate the aggregation from the search results. + +The following table catalogs the aggregation functions and defines how the null and missing values are handled. + +| | | | +|----------|-------------|-------------| +| Function | NULL | MISSING | +| COUNT | Not counted | Not counted | +| SUM | Ignore | Ignore | +| AVG | Ignore | Ignore | +| MAX | Ignore | Ignore | +| MIN | Ignore | Ignore | + +### Syntax + +stats <aggregation>... \[by-clause\]... + +- \`aggregation\`: Required. The aggregation function that must be applied to the field. +- \`by-clause\`: Optional. One or more fields to group by. Default: If \`<by-clause>\` is not specified, the \`stats\` command returns one row, which is the aggregation for the entire result set. + +### Aggregation functions +--- +#### COUNT + +The \`count\` function returns the number of rows in the result set. The following is an example PPL query: + + os> source=accounts | stats count(); + fetched rows / total rows = 1/1 + +-----------+ + | count() | + |-----------| + | 4 | + +-----------+ + +#### SUM + +The \`SUM(expr)\` function returns the sum of the values in the expression \`expr\`. The following is an example PPL query: + + os> source=accounts | stats sum(age) by gender; + fetched rows / total rows = 2/2 + +------------+----------+ + | sum(age) | gender | + |------------+----------| + | 28 | F | + | 101 | M | + +------------+----------+ + +#### AVG + +The \`AVG(expr)\` function returns the average of the values in the expression \`expr\`. The following is an example PPL query: + + os> source=accounts | stats avg(age) by gender; + fetched rows / total rows = 2/2 + +--------------------+----------+ + | avg(age) | gender | + |--------------------+----------| + | 28.0 | F | + | 33.666666666666664 | M | + +--------------------+----------+ + +#### MAX + +The \`MAX(expr)\` function returns the largest value in the expression \`expr\`. The following is an example PPL query: + + os> source=accounts | stats max(age); + fetched rows / total rows = 1/1 + +------------+ + | max(age) | + |------------| + | 36 | + +------------+ + +#### MIN + +The \`MIN(expr)\` function returns the smallest value in the expression \`expr\`. The following is an example PPL query: + + os> source=accounts | stats min(age); + fetched rows / total rows = 1/1 + +------------+ + | min(age) | + |------------| + | 28 | + +------------+ + +#### VAR\_SAMP + +The \`VAR\_SAMP(expr)\` function returns the sample variance of a selection of data in the expression \`expr\`. The following is an example PPL query: + + os> source=accounts | stats var_samp(age); + fetched rows / total rows = 1/1 + +--------------------+ + | var_samp(age) | + |--------------------| + | 10.916666666666666 | + +--------------------+ + +#### VAR\_POP + +The \`VAR\_POP(expr)\` function returns the population variance of a selection of data in the expression \`expr\`. See the following example. + + os> source=accounts | stats var_pop(age); + fetched rows / total rows = 1/1 + +----------------+ + | var_pop(age) | + |----------------| + | 8.1875 | + +----------------+ + +#### STDDEV\_SAMP + +The \`STDDEV\_SAMP(expr)\` function returns the sample standard deviation of a set of values in the expression \`expr\`. The following is an example PPL query: + + os> source=accounts | stats stddev_samp(age); + fetched rows / total rows = 1/1 + +--------------------+ + | stddev_samp(age) | + |--------------------| + | 3.304037933599835 | + +--------------------+ + +#### STDDEV\_POP + +The \`STDDEV\_POP(expr)\` function returns the population standard deviation of a set of values in the expression \`expr\`. The following is an example PPL query: + + os> source=accounts | stats stddev_pop(age); + fetched rows / total rows = 1/1 + +--------------------+ + | stddev_pop(age) | + |--------------------| + | 2.8613807855648994 | + +--------------------+ + +### By clause + +The \`by\` clause can contain fields, expressions, scalar functions, or aggregation functions. The \`span\` clause can be used in the \`by\` clause to split specific fields into buckets of the same interval. The \`stats\` command then performs the aggregation on these buckets. + +The span syntax is \`span(field_expr, interval_expr)\`. By default, the interval expression in the \`span\` clause is interpreted in natural units. If the field is a date and time type field and the interval is in date and time units, you must specify the unit in the interval expression. For example, to split the \`age\` field into buckets of 10 years, you would use \`span(age, 10y). To split a timestamp field into hourly intervals, you would use \`span(timestamp, 1h)\`. + +The following table lists the available time units. + +| Span Interval Units | +|----------------------------| +| millisecond (ms) | +| second (s) | +| minute (m, case sensitive) | +| hour (h) | +| day (d) | +| week (w) | +| month (M, case sensitive) | +| quarter (q) | +| year (y) | + +### PPL queries using the stats command + +The following example PPL queries show ways you can use the \`stats\` command in your queries. + +#### Example 1: Calculate event counts + +The following example PPL query calculates event counts: + + os> source=accounts | stats count(); + fetched rows / total rows = 1/1 + +-----------+ + | count() | + |-----------| + | 4 | + +-----------+ + +#### Example 2: Calculate a field's average + +The following example PPL query calculates the average age: + + os> source=accounts | stats avg(age); + fetched rows / total rows = 1/1 + +------------+ + | avg(age) | + |------------| + | 32.25 | + +------------+ + +#### Example 3: Calculate the average of a field by group + +The following example PPL query calculates the average age grouped by gender: + + os> source=accounts | stats avg(age) by gender; + fetched rows / total rows = 2/2 + +--------------------+----------+ + | avg(age) | gender | + |--------------------+----------| + | 28.0 | F | + | 33.666666666666664 | M | + +--------------------+----------+ + +#### Example 4: Calculate the average, sum, and count of a field by group + +The following example PPL query calculates the average age, sum age, and count of events by gender. + + os> source=accounts | stats avg(age), sum(age), count() grouped by gender; + fetched rows / total rows = 2/2 + +--------------------+------------+-----------+----------+ + | avg(age) | sum(age) | count() | gender | + |--------------------+------------+-----------+----------| + | 28.0 | 28 | 1 | F | + | 33.666666666666664 | 101 | 3 | M | + +--------------------+------------+-----------+----------+ + +#### Example 5: Calculate a field's maximum + +The following example PPL query calculates the maximum age: + + os> source=accounts | stats max(age); + fetched rows / total rows = 1/1 + +------------+ + | max(age) | + |------------| + | 36 | + +------------+ + +#### Example 6: Calculate a field's min/max by group + +The following example PPL query calculates the min/max age grouped by gender: + + os> source=accounts | stats max(age), min(age) by gender; + fetched rows / total rows = 2/2 + +------------+------------+----------+ + | max(age) | min(age) | gender | + |------------+------------+----------| + | 28 | 28 | F | + | 36 | 32 | M | + +------------+------------+----------+ + +#### Example 7: Calculate a field's distinct count + +To count the number of distinct values in a field, you can use the \`DISTINCT_COUNT\` or \`DC\` function instead of the \`COUNT\` funtion. + +The following PPL query calculates both the count and distinct count of the \`gender\` field for all accounts. + + os> source=accounts | stats count(gender), distinct_count(gender); + fetched rows / total rows = 1/1 + +-----------------+--------------------------+ + | count(gender) | distinct_count(gender) | + |-----------------+--------------------------| + | 4 | 2 | + +-----------------+--------------------------+ + +#### Example 8: Calculate count by span + +The following PPL query calculates age by span of 10 years. + + os> source=accounts | stats count(age) by span(age, 10) as age_span + fetched rows / total rows = 2/2 + +--------------+------------+ + | count(age) | age_span | + |--------------+------------| + | 1 | 20 | + | 3 | 30 | + +--------------+------------+ + +#### Example 9: Calculate count by gender and span + +The following PPL query calculates age by span of 10 years and groups by gender. + + os> source=accounts | stats count() as cnt by span(age, 5) as age_span, gender + fetched rows / total rows = 3/3 + +-------+------------+----------+ + | cnt | age_span | gender | + |-------+------------+----------| + | 1 | 25 | F | + | 2 | 30 | M | + | 1 | 35 | M | + +-------+------------+----------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/syntax.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/syntax.ts new file mode 100644 index 000000000000..a37cf8be65ce --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/syntax.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const syntaxCmd = `## Syntax +--- +### Command order + +The PPL query starts with a \`search\` command to reference a table to search. +Commands can be in any order. For example, in the following query, the \`search\` command references the \`accounts\` index as the source and then uses fields and a \`where\` command to perform further processing. + +\`\`\` +search source=accounts +| where age > 18 +| fields firstname, lastname +\`\`\` + +### Required and optional arguments + +Required arguments are enclosed in angle brackets \< \>, and optional arguments are enclosed in square brackets \[ \].`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/top.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/top.ts new file mode 100644 index 000000000000..0cf6b7c8b8de --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/top.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const topCmd = `## top +--- +### Description + +Use the \`top\` command to find the most common tuple of values for all +fields in the field list. + +### Syntax + +top \[N\] <field-list> \[by-clause\] + +- \`N\`: The number of results you want to return. Default is 10. +- \`field-list\`: Required. The comma-delimited field list. +- \`by-clause\`: Optional. One or more fields to group by. + +#### Example 1: Find the most common values in a field + +The following example PPL query finds the most common gender. + + os> source=accounts | top 1 gender; + fetched rows / total rows = 1/1 + +------------+ + | gender | + |------------| + | M | + +------------+ + +#### Example 2: Find the most common values grouped by gender + +The following example PPL query finds the most common age grouped by gender. + + os> source=accounts | top 1 age by gender; + fetched rows / total rows = 2/2 + +----------+----------+ + | gender | age | + |----------+----------| + | F | 39 | + | M | 31 | + +----------+----------+ + +#### Limitation +The \`top\` command is not rewritten to [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/). It is only run on the coordinating node. +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/where.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/where.ts new file mode 100644 index 000000000000..b92d65b01312 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/commands/where.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const whereCmd = `## where +--- + +### Description + +Use the \`where\` command to filter search results. The \`where\` command only returns the result when the \`bool-expression\` is set to \`true\`. + +### Syntax + +\`where <boolean-expression>\` + +- \`bool-expression\`: Optional. Any expression that can be evaluated to a Boolean expression. + +#### Example 1: Filter the result set with a condition + +The following example PPL query fetches all documents from the \`accounts\` index using an \`or\ condition. + + os> source=accounts | where account_number=1 or gender="F" | fields account_number, gender; + fetched rows / total rows = 2/2 + +------------------+----------+ + | account_number | gender | + |------------------+----------| + | 1 | M | + | 13 | F | + +------------------+----------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/condition.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/condition.ts new file mode 100644 index 000000000000..4824b5aa2300 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/condition.ts @@ -0,0 +1,162 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const conditionFunction = `## Condition +--- + +### Condition functions + +PPL functions use the search capabilities of the OpenSearch engine. However, these functions don't execute directly within the OpenSearch plugin's memory. Instead, they facilitate the global filtering of query results based on specific conditions, such as a \`WHERE\` or \`HAVING\` clause. + +The following sections describe the condition PPL functions. + +### ISNULL + +The \`isnull(field)\` function checks a specific field and returns \`true\` if the field contains no data, that is, it's null. + +**Argument type:** All supported data types + +**Return type:** \`BOOLEAN\` + +#### Example + + os> source=accounts | eval result = isnull(employer) | fields result, employer, firstname + fetched rows / total rows = 4/4 + +----------+------------+-------------+ + | result | employer | firstname | + |----------+------------+-------------| + | False | Pyrami | Amber | + | False | Netagy | Hattie | + | False | Quility | Nanette | + | True | null | Dale | + +----------+------------+-------------+ + +### ISNOTNULL + +The \`isnotnull(field)\` function is the opposite of \`isnull(field)\`. Instead of checking for null values, it checks a specific field and returns \`true\` if the field contains data, that is, it is not null. + +**Argument type:** All supported data types + +**Return type:** \`BOOLEAN\` + +#### Example + + os> source=accounts | where not isnotnull(employer) | fields account_number, employer + fetched rows / total rows = 1/1 + +------------------+------------+ + | account_number | employer | + |------------------+------------| + | 18 | null | + +------------------+------------+ + +### EXISTS + +OpenSearch does not differentiate between null and missing. Thus, a function such as \`ismissing\` or \`isnotmissing\` cannot be used to test if a field exists or not. The \`isnull\` or \`isnotnull\` functions can be used for this purpose. + +#### Example + + os> source=accounts | where isnull(email) | fields account_number, email + fetched rows / total rows = 1/1 + +------------------+---------+ + | account_number | email | + |------------------+---------| + | 13 | null | + +------------------+---------+ + +### IFNULL + +The \`ifnull(field1, field2)\` function returns the value in the first field if it is not null; otherwise, it returns the value in the second field. + +**Argument type:** All supported data types (Note that the semantic check will fail if the parameters are different types.) + +**Return type:** Any + +#### Example + + os> source=accounts | eval result = ifnull(employer, 'default') | fields result, employer, firstname + fetched rows / total rows = 4/4 + +----------+------------+-------------+ + | result | employer | firstname | + |----------+------------+-------------| + | Pyrami | Pyrami | Amber | + | Netagy | Netagy | Hattie | + | Quility | Quility | Nanette | + | default | null | Dale | + +----------+------------+-------------+ + +### NULLIF + +The \`nullif(field1, field2)\` function returns \`null\` if the values in both fields are identical. If the values differ, the function returns the value in the first field (field1). + +**Argument type:** All supported data types (Note that the semantic check will fail if the parameters are different types.) + +**Return type:** Any + +#### Example + + os> source=accounts | eval result = nullif(employer, 'Pyrami') | fields result, employer, firstname + fetched rows / total rows = 4/4 + +----------+------------+-------------+ + | result | employer | firstname | + |----------+------------+-------------| + | null | Pyrami | Amber | + | Netagy | Netagy | Hattie | + | Quility | Quility | Nanette | + | null | null | Dale | + +----------+------------+-------------+ + +### ISNULL + +The \`isnull(field1, field2)\` function checks for null values and returns \`null\` if the values in both fields are identical. If the values differ, the function returns the value in the first field (field1). + +**Argument type:** All supported data types + +**Return type:** Any + +#### Example + + os> source=accounts | eval result = isnull(employer) | fields result, employer, firstname + fetched rows / total rows = 4/4 + +----------+------------+-------------+ + | result | employer | firstname | + |----------+------------+-------------| + | False | Pyrami | Amber | + | False | Netagy | Hattie | + | False | Quility | Nanette | + | True | null | Dale | + +----------+------------+-------------+ + +### IF + +The \`if(condition, expr1, expr2)\` function returns \`expr1\` if \`condition\` is \`true\`, and \`expr2\` otherwise. + +**Argument type:** All supported data types (Note that the semantic check will fail if \`expr1\` and \`expr2\` have different types.) + +**Return type:** Any + +Example: + + os> source=accounts | eval result = if(true, firstname, lastname) | fields result, firstname, lastname + fetched rows / total rows = 4/4 + +----------+-------------+------------+ + | result | firstname | lastname | + |----------+-------------+------------| + | Amber | Amber | Duke | + | Hattie | Hattie | Bond | + | Nanette | Nanette | Bates | + | Dale | Dale | Adams | + +----------+-------------+------------+ + + os> source=accounts | eval result = if(false, firstname, lastname) | fields result, firstname, lastname + fetched rows / total rows = 4/4 + +----------+-------------+------------+ + | result | firstname | lastname | + |----------+-------------+------------| + | Duke | Amber | Duke | + | Bond | Hattie | Bond | + | Bates | Nanette | Bates | + | Adams | Dale | Adams | + +----------+-------------+------------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/datetime.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/datetime.ts new file mode 100644 index 000000000000..5b2ca67f50c2 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/datetime.ts @@ -0,0 +1,537 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const datetimeFunction = `## Datetime +--- + +### Datetime functions + +PPL functions use the search capabilities of the OpenSearch engine. However, these functions don't execute directly within the OpenSearch plugin's memory. Instead, they facilitate the global filtering of query results based on specific conditions, such as a \`WHERE\` or \`HAVING\` clause. + +The following sections describe the \`datetime\` PPL functions. + +### ADDDATE + +The \`adddate\` function add a time interval to a date. It supports two forms: adding a specified interval using \`INTERVAL\` keyword or adding an integer number of days directly. + +**Argument type:** \`DATE/DATETIME/TIMESTAMP/STRING, INTERVAL/LONG\` + +**Return type:** \`(DATE/DATETIME/TIMESTAMP/STRING, INTERVAL) -> DATETIME\`, \`(DATE, LONG) -> DATE\`, \`(DATETIME/TIMESTAMP/STRING, LONG) -> DATETIME\` + +**Synonyms**: \`[DATE\_ADD](#date_add)\` + +#### Example + + os> source=people | eval \`ADDDATE(DATE('2020-08-26'), INTERVAL 1 HOUR)\` = ADDDATE(DATE('2020-08-26'), INTERVAL 1 HOUR), \`ADDDATE(DATE('2020-08-26'), 1)\` = ADDDATE(DATE('2020-08-26'), 1), \`ADDDATE(TIMESTAMP('2020-08-26 01:01:01'), 1)\` = ADDDATE(TIMESTAMP('2020-08-26 01:01:01'), 1) | fields \`ADDDATE(DATE('2020-08-26'), INTERVAL 1 HOUR)\`, \`ADDDATE(DATE('2020-08-26'), 1)\`, \`ADDDATE(TIMESTAMP('2020-08-26 01:01:01'), 1)\` + fetched rows / total rows = 1/1 + +------------------------------------------------+----------------------------------+------------------------------------------------+ + | ADDDATE(DATE('2020-08-26'), INTERVAL 1 HOUR) | ADDDATE(DATE('2020-08-26'), 1) | ADDDATE(TIMESTAMP('2020-08-26 01:01:01'), 1) | + |------------------------------------------------+----------------------------------+------------------------------------------------| + | 2020-08-26 01:00:00 | 2020-08-27 | 2020-08-27 01:01:01 | + +------------------------------------------------+----------------------------------+------------------------------------------------+ + +#### DATE + +The \`date(expr)\` function converts strings to date types and extracts the date portion from existing date, datetime, and timestamp values. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`DATE\` + +#### Example + + >od source=people | eval \`DATE('2020-08-26')\` = DATE('2020-08-26'), \`DATE(TIMESTAMP('2020-08-26 13:49:00'))\` = DATE(TIMESTAMP('2020-08-26 13:49:00')) | fields \`DATE('2020-08-26')\`, \`DATE(TIMESTAMP('2020-08-26 13:49:00'))\` + fetched rows / total rows = 1/1 + +----------------------+------------------------------------------+ + | DATE('2020-08-26') | DATE(TIMESTAMP('2020-08-26 13:49:00')) | + |----------------------+------------------------------------------| + | DATE '2020-08-26' | DATE '2020-08-26' | + +----------------------+------------------------------------------+ + +#### DATE\_ADD + +The \`date\_add(date, INTERVAL expr unit)\` or \`date\_add(date, expr)\` adds +the time interval specified by \`expr\` to a given \`date\`. It supports adding a specific interval and adding an integer number of days. + +**Argument type:** \`DATE/DATETIME/TIMESTAMP/STRING, INTERVAL/LONG\` + +**Return type:** \`DATE/DATETIME/TIMESTAMP/STRING, INTERVAL ->\`, \`DATETIME\`, \`DATE, LONG -> DATE\`, \`DATETIME/TIMESTAMP/STRING, LONG -> DATETIME\` + +**Synonyms:** \`[ADDDATE](#adddate)\` + +#### Example + + os> source=people | eval \`DATE_ADD(DATE('2020-08-26'), INTERVAL 1 HOUR)\` = DATE_ADD(DATE('2020-08-26'), INTERVAL 1 HOUR), \`DATE_ADD(DATE('2020-08-26'), 1)\` = DATE_ADD(DATE('2020-08-26'), 1), \`DATE_ADD(TIMESTAMP('2020-08-26 01:01:01'), 1)\` = DATE_ADD(TIMESTAMP('2020-08-26 01:01:01'), 1) | fields \`DATE_ADD(DATE('2020-08-26'), INTERVAL 1 HOUR)\`, \`DATE_ADD(DATE('2020-08-26'), 1)\`, \`DATE_ADD(TIMESTAMP('2020-08-26 01:01:01'), 1)\` + fetched rows / total rows = 1/1 + +-------------------------------------------------+-----------------------------------+-------------------------------------------------+ + | DATE_ADD(DATE('2020-08-26'), INTERVAL 1 HOUR) | DATE_ADD(DATE('2020-08-26'), 1) | DATE_ADD(TIMESTAMP('2020-08-26 01:01:01'), 1) | + |-------------------------------------------------+-----------------------------------+-------------------------------------------------| + | 2020-08-26 01:00:00 | 2020-08-27 | 2020-08-27 01:01:01 | + +-------------------------------------------------+-----------------------------------+-------------------------------------------------+ + +### DATE\_FORMAT + +The \`date\_format(date, format)\` function takes a date and a format string as arguments and returns the formatted date string according to the specified format. + +The following table lists the available specifier arguments. + +| Specifier | Description | +|-----------|-----------------------------------------------------------| +| %a | Abbreviated weekday name (Sun..Sat) | +| %b | Abbreviated month name (Jan..Dec) | +| %c | Month, numeric (0..12) | +| %D | Day of the month with English suffix (0th, 1st, 2nd, 3rd, …) | +| %d | Day of the month, numeric (00..31) | +| %e | Day of the month, numeric (0..31) | +| %f | Microseconds (000000..999999) | +| %H | Hour (00..23) | +| %h | Hour (01..12) | +| %I | Hour (01..12) | +| %i | Minutes, numeric (00..59) | +| %j | Day of year (001..366) | +| %k | Hour (0..23) | +| %l | Hour (1..12) | +| %M | Month name (January..December) | +| %m | Month, numeric (00..12) | +| %p | AM or PM | +| %r | Time, 12-hour (hh:mm:ss followed by AM or PM) | +| %S | Seconds (00..59) | +| %s | Seconds (00..59) | +| %T | Time, 24-hour (hh:mm:ss) | +| %U | Week (00..53), where Sunday is the first day of the week; WEEK() mode 0 | +| %u | Week (00..53), where Monday is the first day of the week; WEEK() mode 1 | +| %V | Week (01..53), where Sunday is the first day of the week; WEEK() mode 2; used with %X | +| %v | Week (01..53), where Monday is the first day of the week; WEEK() mode 3; used with %x | +| %W | Weekday name (Sunday..Saturday) | +| %w | Day of the week (0=Sunday..6=Saturday) | +| %X | Year for the week where Sunday is the first day of the week, numeric, four digits; used with %V | +| %x | Year for the week, where Monday is the first day of the week, numeric, four digits; used with %v | +| %Y | Year, numeric, four digits | +| %y | Year, numeric (two digits) | +| %% | A literal % character | +| %x | x, for any “x” not listed above | + +**Argument type:** STRING/DATE/DATETIME/TIMESTAMP, STRING + +**Return type:** STRING + +#### Example + + >od source=people | eval \`DATE_FORMAT('1998-01-31 13:14:15.012345', '%T.%f')\` = DATE_FORMAT('1998-01-31 13:14:15.012345', '%T.%f'), \`DATE_FORMAT(TIMESTAMP('1998-01-31 13:14:15.012345'), '%Y-%b-%D %r')\` = DATE_FORMAT(TIMESTAMP('1998-01-31 13:14:15.012345'), '%Y-%b-%D %r') | fields \`DATE_FORMAT('1998-01-31 13:14:15.012345', '%T.%f')\`, \`DATE_FORMAT(TIMESTAMP('1998-01-31 13:14:15.012345'), '%Y-%b-%D %r')\` + fetched rows / total rows = 1/1 + +-----------------------------------------------+----------------------------------------------------------------+ + | DATE('1998-01-31 13:14:15.012345', '%T.%f') | DATE(TIMESTAMP('1998-01-31 13:14:15.012345'), '%Y-%b-%D %r') | + |-----------------------------------------------+----------------------------------------------------------------| + | '13:14:15.012345' | '1998-Jan-31st 01:14:15 PM' | + +-----------------------------------------------+----------------------------------------------------------------+ + +### DATE\_SUB + +**Description** + +Usage: date\_sub(date, INTERVAL expr unit)/ date\_sub(date, expr) +subtracts the time interval expr from date + +Argument type: \`DATE/DATETIME/TIMESTAMP/STRING, INTERVAL/LONG\` + +**Return type:** \`DATE/DATETIME/TIMESTAMP/STRING, INTERVAL -> DATETIME\`, \`DATE, LONG -> DATE\`, \`DATETIME/TIMESTAMP/STRING, LONG -> DATETIME\` + +**Synonyms:** \`[SUBDATE](#subdate)\` + +#### Example + + os> source=people | eval \`DATE_SUB(DATE('2008-01-02'), INTERVAL 31 DAY)\` = DATE_SUB(DATE('2008-01-02'), INTERVAL 31 DAY), \`DATE_SUB(DATE('2020-08-26'), 1)\` = DATE_SUB(DATE('2020-08-26'), 1), \`DATE_SUB(TIMESTAMP('2020-08-26 01:01:01'), 1)\` = DATE_SUB(TIMESTAMP('2020-08-26 01:01:01'), 1) | fields \`DATE_SUB(DATE('2008-01-02'), INTERVAL 31 DAY)\`, \`DATE_SUB(DATE('2020-08-26'), 1)\`, \`DATE_SUB(TIMESTAMP('2020-08-26 01:01:01'), 1)\` + fetched rows / total rows = 1/1 + +-------------------------------------------------+-----------------------------------+-------------------------------------------------+ + | DATE_SUB(DATE('2008-01-02'), INTERVAL 31 DAY) | DATE_SUB(DATE('2020-08-26'), 1) | DATE_SUB(TIMESTAMP('2020-08-26 01:01:01'), 1) | + |-------------------------------------------------+-----------------------------------+-------------------------------------------------| + | 2007-12-02 | 2020-08-25 | 2020-08-25 01:01:01 | + +-------------------------------------------------+-----------------------------------+-------------------------------------------------+ + +### DAY + +The \`day(date)\` function retrieves the day of the month (1-31) for a provided \`date\`. Note that dated with a value of 0, such as "0000-00-00" or "2008-00-00", are considered invalid. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +**Synonyms:** \`DAYOFMONTH\` + +#### Example + + os> source=people | eval \`DAY(DATE('2020-08-26'))\` = DAY(DATE('2020-08-26')) | fields \`DAY(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +---------------------------+ + | DAY(DATE('2020-08-26')) | + |---------------------------| + | 26 | + +---------------------------+ + +### DAYNAME + +The \`dayname(date)\` function retrieves the full name of the weekday, for example, Monday, Tuesday, and so forth, for a given \`date\`. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`DAYNAME(DATE('2020-08-26'))\` = DAYNAME(DATE('2020-08-26')) | fields \`DAYNAME(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +-------------------------------+ + | DAYNAME(DATE('2020-08-26')) | + |-------------------------------| + | Wednesday | + +-------------------------------+ + +### DAYOFMONTH + +The \`dayofmonth(date)\` function retrieves the day of the month (1-31) for a provided \`date\`. Note that dated with a value of 0, such as "0000-00-00" or "2008-00-00", are considered invalid. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +**Synonyms:** \`DAY\` + +#### Example + + os> source=people | eval \`DAYOFMONTH(DATE('2020-08-26'))\` = DAYOFMONTH(DATE('2020-08-26')) | fields \`DAYOFMONTH(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +----------------------------------+ + | DAYOFMONTH(DATE('2020-08-26')) | + |----------------------------------| + | 26 | + +----------------------------------+ + +### DAYOFWEEK + +The \`dayofweek(date)\` retrieves the numerical index (1-7) representing the weekday for a given \`date\`, where 1 corresponds to Sunday and 7 corresponds to Saturday. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`DAYOFWEEK(DATE('2020-08-26'))\` = DAYOFWEEK(DATE('2020-08-26')) | fields \`DAYOFWEEK(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +---------------------------------+ + | DAYOFWEEK(DATE('2020-08-26')) | + |---------------------------------| + | 4 | + +---------------------------------+ + +### DAYOFYEAR + +The \`dayofyear(date)\` function retrieves the day of the year for a given \`date\`, ranging from 1 to 366. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`DAYOFYEAR(DATE('2020-08-26'))\` = DAYOFYEAR(DATE('2020-08-26')) | fields \`DAYOFYEAR(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +---------------------------------+ + | DAYOFYEAR(DATE('2020-08-26')) | + |---------------------------------| + | 239 | + +---------------------------------+ + +### FROM\_DAYS + +The \`from\_days(N)\` function retrieves the date value corresponding to the provided day number \`N\`. + +**Argument type:** \`INTEGER/LONG\` + +**Return type:** \`DATE\` + +#### Example + + os> source=people | eval \`FROM_DAYS(733687)\` = FROM_DAYS(733687) | fields \`FROM_DAYS(733687)\` + fetched rows / total rows = 1/1 + +---------------------+ + | FROM_DAYS(733687) | + |---------------------| + | 2008-10-07 | + +---------------------+ + +### HOUR + +The \`hour(time)\` function extracts the hour value from a given \`time\`. Unlike the typical time-of-day format wher hours range from 0 to 23, the \`time\` input can have a larger range. Therefore, the \`hour(time)\` function may return values exceeding 23. + +**Argument type:** \`STRING/TIME/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`HOUR(TIME('01:02:03'))\` = HOUR(TIME('01:02:03')) | fields \`HOUR(TIME('01:02:03'))\` + fetched rows / total rows = 1/1 + +--------------------------+ + | HOUR(TIME('01:02:03')) | + |--------------------------| + | 1 | + +--------------------------+ + +### MAKETIME + +**Function signature:** \`MAKETIME(INTEGER, INTEGER, INTEGER) -> DATE\` + +### MICROSECOND + +The \`microsecond(expr)\` function retrieves the microsecond portion (0-999999) from a given \`time\` or \`datetime\` expression. + +**Argument type:** \`STRING/TIME/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`MICROSECOND(TIME('01:02:03.123456'))\` = MICROSECOND(TIME('01:02:03.123456')) | fields \`MICROSECOND(TIME('01:02:03.123456'))\` + fetched rows / total rows = 1/1 + +----------------------------------------+ + | MICROSECOND(TIME('01:02:03.123456')) | + |----------------------------------------| + | 123456 | + +----------------------------------------+ + +### MINUTE + +The \`minute(time)\` extracts the minute value (0-59) from a given \`time\` expression. + +**Argument type:** \`STRING/TIME/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`MINUTE(TIME('01:02:03'))\` = MINUTE(TIME('01:02:03')) | fields \`MINUTE(TIME('01:02:03'))\` + fetched rows / total rows = 1/1 + +----------------------------+ + | MINUTE(TIME('01:02:03')) | + |----------------------------| + | 2 | + +----------------------------+ + +### MONTH + +The \`month(date)\` function extracts the month (1-12) from a valid \`date\` value. However, invalid dates containing 0 values for the month, such as "0000-00-00" or "2008-00-00" are considered invalid. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`MONTH(DATE('2020-08-26'))\` = MONTH(DATE('2020-08-26')) | fields \`MONTH(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +-----------------------------+ + | MONTH(DATE('2020-08-26')) | + |-----------------------------| + | 8 | + +-----------------------------+ + +### MONTHNAME + +The \`monthname(date)\` function retrieves the full name of the month, for example, January, February, and so forth, for a given \`date\`. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`MONTHNAME(DATE('2020-08-26'))\` = MONTHNAME(DATE('2020-08-26')) | fields \`MONTHNAME(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +---------------------------------+ + | MONTHNAME(DATE('2020-08-26')) | + |---------------------------------| + | August | + +---------------------------------+ + +### NOW + +**Function signature:** NOW() -> DATE + +### QUARTER + +The \`quarter(date)\` function retrieves the quarter (1-4) for a given \`date\`. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`QUARTER(DATE('2020-08-26'))\` = QUARTER(DATE('2020-08-26')) | fields \`QUARTER(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +-------------------------------+ + | QUARTER(DATE('2020-08-26')) | + |-------------------------------| + | 3 | + +-------------------------------+ + +### SECOND + +The \`second(time)\` function extracts the second value (0-59) from a given \`time\` expression. + +**Argument type:** \`STRING/TIME/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`SECOND(TIME('01:02:03'))\` = SECOND(TIME('01:02:03')) | fields \`SECOND(TIME('01:02:03'))\` + fetched rows / total rows = 1/1 + +----------------------------+ + | SECOND(TIME('01:02:03')) | + |----------------------------| + | 3 | + +----------------------------+ + +### SUBDATE + +The \`subdate(date, INTERVAL expr unit)\` or \`subdate(date, expr)\` function subtracts a time interval from a date. + +**Argument type:** \`DATE/DATETIME/TIMESTAMP/STRING, INTERVAL/LONG\` + +**Return type:** \`DATE/DATETIME/TIMESTAMP/STRING, INTERVAL -> DATETIME\`, \`DATE, LONG -> DATE\`, \`DATETIME/TIMESTAMP/STRING, LONG -> DATETIME\` + +**Synonyms:** \`[DATE\_SUB](#date_sub)\` + +#### Example + + os> source=people | eval \`SUBDATE(DATE('2008-01-02'), INTERVAL 31 DAY)\` = SUBDATE(DATE('2008-01-02'), INTERVAL 31 DAY), \`SUBDATE(DATE('2020-08-26'), 1)\` = SUBDATE(DATE('2020-08-26'), 1), \`SUBDATE(TIMESTAMP('2020-08-26 01:01:01'), 1)\` = SUBDATE(TIMESTAMP('2020-08-26 01:01:01'), 1) | fields \`SUBDATE(DATE('2008-01-02'), INTERVAL 31 DAY)\`, \`SUBDATE(DATE('2020-08-26'), 1)\`, \`SUBDATE(TIMESTAMP('2020-08-26 01:01:01'), 1)\` + fetched rows / total rows = 1/1 + +------------------------------------------------+----------------------------------+------------------------------------------------+ + | SUBDATE(DATE('2008-01-02'), INTERVAL 31 DAY) | SUBDATE(DATE('2020-08-26'), 1) | SUBDATE(TIMESTAMP('2020-08-26 01:01:01'), 1) | + |------------------------------------------------+----------------------------------+------------------------------------------------| + | 2007-12-02 | 2020-08-25 | 2020-08-25 01:01:01 | + +------------------------------------------------+----------------------------------+------------------------------------------------+ + +### TIME + +The \`time(expr)\` function has dual functionality. If \`expr\` is a string, it contructs a \`time\` object from the provided time value format. Conversly, for input of the type \`date\`, \`datetime\`. \`time\`, or \`timestamp\`, it extracts and returns the pure time component from the given expression. + +**Argument type:** \`STRING/DATE/DATETIME/TIME/TIMESTAMP\` + +**Return type:** \`TIME\` + +#### Example + + >od source=people | eval \`TIME('13:49:00')\` = TIME('13:49:00'), \`TIME(TIMESTAMP('2020-08-26 13:49:00'))\` = TIME(TIMESTAMP('2020-08-26 13:49:00')) | fields \`TIME('13:49:00')\`, \`TIME(TIMESTAMP('2020-08-26 13:49:00'))\` + fetched rows / total rows = 1/1 + +--------------------+------------------------------------------+ + | TIME('13:49:00') | TIME(TIMESTAMP('2020-08-26 13:49:00')) | + |--------------------+------------------------------------------| + | TIME '13:49:00' | TIME '13:49:00' | + +--------------------+------------------------------------------+ + +### TIME\_TO\_SEC + +The \`time\_to\_sec(time)\` function transforms a given \`time\` value into its corresponding number of seconds. + +**Argument type:** \`STRING/TIME/DATETIME/TIMESTAMP\` + +**Return type:** \`LONG\` + +#### Example + + os> source=people | eval \`TIME_TO_SEC(TIME('22:23:00'))\` = TIME_TO_SEC(TIME('22:23:00')) | fields \`TIME_TO_SEC(TIME('22:23:00'))\` + fetched rows / total rows = 1/1 + +---------------------------------+ + | TIME_TO_SEC(TIME('22:23:00')) | + |---------------------------------| + | 80580 | + +---------------------------------+ + +### TIMESTAMP + +The \`timestamp(expr)\` function serves a dual purpose: it can both construct a timestamp object from a string representing a time value or act as a caster, converting exsiting date, datetime, or timestamp objects to a standardized timestamp type with the default UTC time zone. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`TIMESTAMP\` + +#### Example + + >od source=people | eval \`TIMESTAMP('2020-08-26 13:49:00')\` = TIMESTAMP('2020-08-26 13:49:00') | fields \`TIMESTAMP('2020-08-26 13:49:00')\` + fetched rows / total rows = 1/1 + +------------------------------------+ + | TIMESTAMP('2020-08-26 13:49:00') | + |------------------------------------| + | TIMESTAMP '2020-08-26 13:49:00 | + +------------------------------------+ + +### TO\_DAYS + +The \`to\_days(date)\` function calculates the number of days that have elapsed since the year 0 for a given \`date\`. If the provided date is invalid, it returns \`NULL\`. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`LONG\` + +#### Example + + os> source=people | eval \`TO_DAYS(DATE('2008-10-07'))\` = TO_DAYS(DATE('2008-10-07')) | fields \`TO_DAYS(DATE('2008-10-07'))\` + fetched rows / total rows = 1/1 + +-------------------------------+ + | TO_DAYS(DATE('2008-10-07')) | + |-------------------------------| + | 733687 | + +-------------------------------+ + +### WEEK + +The \`week(date\[, mode\])\` function extracts the week number for a given \`date\`. If the mode argument is omitted, the default mode 0 is used. The following table lists the mode arguments. + +| Mode | First day of week | Range | Week 1 is the first week … | +|------|-------------------|-------|-------------------------------| +| 0 | Sunday | 0-53 | with a Sunday in this year | +| 1 | Monday | 0-53 | with 4 or more days this year | +| 2 | Sunday | 1-53 | with a Sunday in this year | +| 3 | Monday | 1-53 | with 4 or more days this year | +| 4 | Sunday | 0-53 | with 4 or more days this year | +| 5 | Monday | 0-53 | with a Monday in this year | +| 6 | Sunday | 1-53 | with 4 or more days this year | +| 7 | Monday | 1-53 | with a Monday in this year | + +**Argument type:** \`DATE/DATETIME/TIMESTAMP/STRING\` + +**Return type:** \`INTEGER\` + +#### Example + + >od source=people | eval \`WEEK(DATE('2008-02-20'))\` = WEEK(DATE('2008-02-20')), \`WEEK(DATE('2008-02-20'), 1)\` = WEEK(DATE('2008-02-20'), 1) | fields \`WEEK(DATE('2008-02-20'))\`, \`WEEK(DATE('2008-02-20'), 1)\` + fetched rows / total rows = 1/1 + +----------------------------+-------------------------------+ + | WEEK(DATE('2008-02-20')) | WEEK(DATE('2008-02-20'), 1) | + |----------------------------|-------------------------------| + | 7 | 8 | + +----------------------------+-------------------------------+ + +### YEAR + +The \`year(date)\` function extracts the year component from a given \`date\` value. However, it only returns valid years within the range of 1000 to 9999. If the provided date is invalid or falls outside this range, the function returns 0. + +**Argument type:** \`STRING/DATE/DATETIME/TIMESTAMP\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`YEAR(DATE('2020-08-26'))\` = YEAR(DATE('2020-08-26')) | fields \`YEAR(DATE('2020-08-26'))\` + fetched rows / total rows = 1/1 + +----------------------------+ + | YEAR(DATE('2020-08-26')) | + |----------------------------| + | 2020 | + +----------------------------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/full_text_search.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/full_text_search.ts new file mode 100644 index 000000000000..7f31bae65166 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/full_text_search.ts @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const fullTextSearchFunction = `## Full-text search +--- + +### Full-text search function + +PPL functions use the search capabilities of the OpenSearch engine. However, these functions don't execute directly within the OpenSearch plugin's memory. Instead, they facilitate the global filtering of query results based on specific conditions, such as a \`WHERE\` or \`HAVING\` clause. + +Full-text search allows for searching by full-text queries. For details about full-text search in OpenSearch, see the [Full-text search](https://opensearch.org/docs/latest/search-plugins/sql/full-text/) documentation. + +### MATCH + +The \`match\` function maps user-defined criteria to OpenSearch queries, returning documents that match specific text, number, date, or Boolean values. + +The function signature is \`match(field_expression, query_expression[, option=]*)\`. + +The available parameters are: + +- analyzer +- auto\_generate\_synonyms\_phrase +- fuzziness +- max\_expansions +- prefix\_length +- fuzzy\_transpositions +- fuzzy\_rewrite +- lenient +- operator +- minimum\_should\_match +- zero\_terms\_query +- boost + +**Example 1: Using specific expressions and default values** + +The following example PPL query uses only the \`field\` and \`query\` expressions, with all other parameters set to their default values: + + os> source=accounts | where match(address, 'Street') | fields lastname, address; + fetched rows / total rows = 2/2 + +------------+--------------------+ + | lastname | address | + |------------+--------------------| + | Bond | 671 Bristol Street | + | Bates | 789 Madison Street | + +------------+--------------------+ + +**Example 2: Setting custom values for optional parameters** + +The following example PPL query sets custom values for the optional parameters: + + os> source=accounts | where match(firstname, 'Hattie', operator='AND', boost=2.0) | fields lastname; + fetched rows / total rows = 1/1 + +------------+ + | lastname | + |------------| + | Bond | + +------------+ + +### Limitations + +The full-text search functions can be executed only in [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/), not in-memory. + +To ensure optimal performance and avoid translation issues with complex full-text searhes, place them as clauses within the search command. + +#### Example + +The following is an example complex query that could fail because it is difficult to translate to query DSL: + + \`search source = people | rename firstname as name | dedup account_number | fields name, account_number, balance, employer | where match(employer, 'Open Search') | stats count() by city\` + +To optimize full-text search performance, rewrite the query by placing the \`WHERE\` clause with the full-text search function as the second command after the \`SEARCH\` command. This ensures the full-text search gets pushed down to query DSL. The following is an example query: + + \`search source = people | where match(employer, 'Open Search') | rename firstname as name | dedup account_number | fields name, account_number, balance, employer | stats count() by city\` + +For details about query engine optimization, see the [Optimizations](https://github.com/opensearch-project/sql/blob/22924b13d9cb46759c8d213a7ce903effe06ab47/docs/user/optimization/optimization.rst) developer documentation on GitHub. +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/index.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/index.ts new file mode 100644 index 000000000000..e061c6188e43 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { datetimeFunction } from './datetime'; +export { conditionFunction } from './condition'; +export { mathFunction } from './math'; +export { stringFunction } from './string'; +export { fullTextSearchFunction } from './full_text_search'; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/math.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/math.ts new file mode 100644 index 000000000000..ed189c3eb42c --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/math.ts @@ -0,0 +1,518 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const mathFunction = `## Math +--- + +### Math functions + +PPL functions use the search capabilities of the OpenSearch engine. However, these functions don't execute directly within the OpenSearch plugin's memory. Instead, they facilitate the global filtering of query results based on specific conditions, such as a \`WHERE\` or \`HAVING\` clause. + +The following sections describe the \`math\` PPL functions. + +### ABS + +The \`abs\` function is an absolute value function. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +#### Example + + os> source=people | eval \`ABS(-1)\` = ABS(-1) | fields \`ABS(-1)\` + fetched rows / total rows = 1/1 + +-----------+ + | ABS(-1) | + |-----------| + | 1 | + +-----------+ + +### ACOS + +The \`acos(x)\` function is an arc cosine function. The function expects values in the range of \`-1\` to \`1\` and returns \`NULL\` if the values aren't in that range. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`ACOS(0)\` = ACOS(0) | fields \`ACOS(0)\` + fetched rows / total rows = 1/1 + +--------------------+ + | ACOS(0) | + |--------------------| + | 1.5707963267948966 | + +--------------------+ + +### ASIN + +The \`asin(x)\` function is an arc sine function. The function expects values in the range of \`-1\` to \`1\` and returns \`NULL\` if the values aren't in that range. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`ASIN(0)\` = ASIN(0) | fields \`ASIN(0)\` + fetched rows / total rows = 1/1 + +-----------+ + | ASIN(0) | + |-----------| + | 0.0 | + +-----------+ + +### ATAN + +The \`atan(x)\` function is an arc tangent function that returns an arc tangent of a value \`x\`. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`ATAN(2)\` = ATAN(2), \`ATAN(2, 3)\` = ATAN(2, 3) | fields \`ATAN(2)\`, \`ATAN(2, 3)\` + fetched rows / total rows = 1/1 + +--------------------+--------------------+ + | ATAN(2) | ATAN(2, 3) | + |--------------------+--------------------| + | 1.1071487177940904 | 0.5880026035475675 | + +--------------------+--------------------+ + +### ATAN2 + +The \`atan2(y, x)\` function is an arc tangent function that calculates the angle from a specified point to the coordinate origin as measured from the positive x-axis. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`ATAN2(2, 3)\` = ATAN2(2, 3) | fields \`ATAN2(2, 3)\` + fetched rows / total rows = 1/1 + +--------------------+ + | ATAN2(2, 3) | + |--------------------| + | 0.5880026035475675 | + +--------------------+ + +### CEIL + +The \`ceil(x)\` function returns the smallest integer value that is greater than or equal to the specified value. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`CEIL(2.75)\` = CEIL(2.75) | fields \`CEIL(2.75)\` + fetched rows / total rows = 1/1 + +--------------+ + | CEIL(2.75) | + |--------------| + | 3 | + +--------------+ + +### CONV + +The \`CONV(x, a, b)\` function converts the number \`x\` from \`a\` base to \`b\` base. + +**Argument type:** \`x: STRING, a: INTEGER, b: INTEGER\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`CONV('12', 10, 16)\` = CONV('12', 10, 16), \`CONV('2C', 16, 10)\` = CONV('2C', 16, 10), \`CONV(12, 10, 2)\` = CONV(12, 10, 2), \`CONV(1111, 2, 10)\` = CONV(1111, 2, 10) | fields \`CONV('12', 10, 16)\`, \`CONV('2C', 16, 10)\`, \`CONV(12, 10, 2)\`, \`CONV(1111, 2, 10)\` + fetched rows / total rows = 1/1 + +----------------------+----------------------+-------------------+---------------------+ + | CONV('12', 10, 16) | CONV('2C', 16, 10) | CONV(12, 10, 2) | CONV(1111, 2, 10) | + |----------------------+----------------------+-------------------+---------------------| + | c | 44 | 1100 | 15 | + +----------------------+----------------------+-------------------+---------------------+ + +### COS + +The \`cos(x)\` function is a cosine function, with \`x\` in radians. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`COS(0)\` = COS(0) | fields \`COS(0)\` + fetched rows / total rows = 1/1 + +----------+ + | COS(0) | + |----------| + | 1.0 | + +----------+ + +### COT + +The \`cot(x)\` function is a cotangent function. An out-of-range error is returned if \`x\` equals \`0\`. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`COT(1)\` = COT(1) | fields \`COT(1)\` + fetched rows / total rows = 1/1 + +--------------------+ + | COT(1) | + |--------------------| + | 0.6420926159343306 | + +--------------------+ + +### CRC32 + +The \`crc32\` function calculates the cyclic redundancy check (CRC) value of a given string as a 32-bit unsigned value. + +**Argument type:** \`STRING\` + +**Return type:** \`LONG\` + +#### Example + + os> source=people | eval \`CRC32('MySQL')\` = CRC32('MySQL') | fields \`CRC32('MySQL')\` + fetched rows / total rows = 1/1 + +------------------+ + | CRC32('MySQL') | + |------------------| + | 3259397556 | + +------------------+ + +### DEGREES + +The \`degrees(x)\` function converts \`x\` from radians to degrees. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`DEGREES(1.57)\` = DEGREES(1.57) | fields \`DEGREES(1.57)\` + fetched rows / total rows = 1/1 + +-------------------+ + | DEGREES(1.57) | + |-------------------| + | 89.95437383553924 | + +-------------------+ + +### E + +The \`E()\` function returns Euler's number. + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`E()\` = E() | fields \`E()\` + fetched rows / total rows = 1/1 + +-------------------+ + | E() | + |-------------------| + | 2.718281828459045 | + +-------------------+ + +### EXP + +The \`exp(x)\` function returns \`e\` raised to the power of \`x\`. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`EXP(2)\` = EXP(2) | fields \`EXP(2)\` + fetched rows / total rows = 1/1 + +------------------+ + | EXP(2) | + |------------------| + | 7.38905609893065 | + +------------------+ + +### FLOOR + +The \`floor(x)\` function returns the largest integer less than or equal to the specified value. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`FLOOR(2.75)\` = FLOOR(2.75) | fields \`FLOOR(2.75)\` + fetched rows / total rows = 1/1 + +---------------+ + | FLOOR(2.75) | + |---------------| + | 2 | + +---------------+ + +### LN + +The \`ln(x)\` function returns the natural logarithm of \`x\`. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`LN(2)\` = LN(2) | fields \`LN(2)\` + fetched rows / total rows = 1/1 + +--------------------+ + | LN(2) | + |--------------------| + | 0.6931471805599453 | + +--------------------+ + +### LOG + +The \`log(x)\` function returns the natural logarithm of \`x\`. + +**Argument type: \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`LOG(2)\` = LOG(2), \`LOG(2, 8)\` = LOG(2, 8) | fields \`LOG(2)\`, \`LOG(2, 8)\` + fetched rows / total rows = 1/1 + +--------------------+-------------+ + | LOG(2) | LOG(2, 8) | + |--------------------+-------------| + | 0.6931471805599453 | 3.0 | + +--------------------+-------------+ + +### LOG2 + +The \`log2(x)\` function calculates the base-2 logarithm of \`x\`. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`LOG2(8)\` = LOG2(8) | fields \`LOG2(8)\` + fetched rows / total rows = 1/1 + +-----------+ + | LOG2(8) | + |-----------| + | 3.0 | + +-----------+ + +### LOG10 + +The \`log10(x)\` function calculates the base-10 logarithm of \`x\`. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`LOG10(100)\` = LOG10(100) | fields \`LOG10(100)\` + fetched rows / total rows = 1/1 + +--------------+ + | LOG10(100) | + |--------------| + | 2.0 | + +--------------+ + +### MOD + +The \`MOD(n, m)\` function calculates the remainder of the number \`n\` divided by \`m\`. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** If \`m\` is a nonzero value, a type greater in size between types \`n\` and \`m\` is returned. If \`m\` equals \`0\`, \`NULL\` is returned. + +#### Example + + os> source=people | eval \`MOD(3, 2)\` = MOD(3, 2), \`MOD(3.1, 2)\` = MOD(3.1, 2) | fields \`MOD(3, 2)\`, \`MOD(3.1, 2)\` + fetched rows / total rows = 1/1 + +-------------+---------------+ + | MOD(3, 2) | MOD(3.1, 2) | + |-------------+---------------| + | 1 | 1.1 | + +-------------+---------------+ + +### PI + +The \`PI()\` function returns the constant pi. + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`PI()\` = PI() | fields \`PI()\` + fetched rows / total rows = 1/1 + +-------------------+ + | PI() | + |-------------------| + | 3.141592653589793 | + +-------------------+ + +### POW + +The \`POW(x, y)\` function calculates the value of \`x\` raised to the power of \`y\`. Bad inputs return \`NULL\` results. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +**Synonyms:** \`[POWER](#power)\` + +#### Example + + os> source=people | eval \`POW(3, 2)\` = POW(3, 2), \`POW(-3, 2)\` = POW(-3, 2), \`POW(3, -2)\` = POW(3, -2) | fields \`POW(3, 2)\`, \`POW(-3, 2)\`, \`POW(3, -2)\` + fetched rows / total rows = 1/1 + +-------------+--------------+--------------------+ + | POW(3, 2) | POW(-3, 2) | POW(3, -2) | + |-------------+--------------+--------------------| + | 9.0 | 9.0 | 0.1111111111111111 | + +-------------+--------------+--------------------+ + +### POWER + +The \`POWER(x, y)\` function calculates the value of \`x\` raised to the power of \`y\`. Bad inputs return \`NULL\` results. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +**Synonyms:** \`[POW](#pow)\` + +#### Example + + os> source=people | eval \`POWER(3, 2)\` = POWER(3, 2), \`POWER(-3, 2)\` = POWER(-3, 2), \`POWER(3, -2)\` = POWER(3, -2) | fields \`POWER(3, 2)\`, \`POWER(-3, 2)\`, \`POWER(3, -2)\` + fetched rows / total rows = 1/1 + +---------------+----------------+--------------------+ + | POWER(3, 2) | POWER(-3, 2) | POWER(3, -2) | + |---------------+----------------+--------------------| + | 9.0 | 9.0 | 0.1111111111111111 | + +---------------+----------------+--------------------+ + +### RADIANS + +The \`radians(x)\` function converts \`x\` from degrees to radians. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`RADIANS(90)\` = RADIANS(90) | fields \`RADIANS(90)\` + fetched rows / total rows = 1/1 + +--------------------+ + | RADIANS(90) | + |--------------------| + | 1.5707963267948966 | + +--------------------+ + +### RAND + +The \`RAND()/RAND(N)\` function returns a random floating-point value in the range \`0 <= value < 1.0\`. If integer \`N\` is specified, the seed is initialized prior to execution. One implication of this behavior is that with identical argument \`N\`, \`rand(N)\` returns the same value each time and thus produces a repeatable sequence of column values. + +**Argument type:** \`INTEGER\` + +**Return type:** \`FLOAT\` + +#### Example + + os> source=people | eval \`RAND(3)\` = RAND(3) | fields \`RAND(3)\` + fetched rows / total rows = 1/1 + +------------+ + | RAND(3) | + |------------| + | 0.73105735 | + +------------+ + +### ROUND + +The \`ROUND(x, d)\` function rounds the argument \`x\` to \`d\` decimal places. \`d\` defaults to \`0\` if a value is not specified. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`(INTEGER/LONG \[,INTEGER\]) -> LONG (FLOAT/DOUBLE \[,INTEGER\]) ->LONG\` + +#### Example + + os> source=people | eval \`ROUND(12.34)\` = ROUND(12.34), \`ROUND(12.34, 1)\` = ROUND(12.34, 1), \`ROUND(12.34, -1)\` = ROUND(12.34, -1), \`ROUND(12, 1)\` = ROUND(12, 1) | fields \`ROUND(12.34)\`, \`ROUND(12.34, 1)\`, \`ROUND(12.34, -1)\`, \`ROUND(12, 1)\` + fetched rows / total rows = 1/1 + +----------------+-------------------+--------------------+----------------+ + | ROUND(12.34) | ROUND(12.34, 1) | ROUND(12.34, -1) | ROUND(12, 1) | + |----------------+-------------------+--------------------+----------------| + | 12.0 | 12.3 | 10.0 | 12 | + +----------------+-------------------+--------------------+----------------+ + +### SIGN + +The \`sign\` function returns the sign of the argument as -1, 0, or 1, depending on whether the number is negative, zero, or positive. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`SIGN(1)\` = SIGN(1), \`SIGN(0)\` = SIGN(0), \`SIGN(-1.1)\` = SIGN(-1.1) | fields \`SIGN(1)\`, \`SIGN(0)\`, \`SIGN(-1.1)\` + fetched rows / total rows = 1/1 + +-----------+-----------+--------------+ + | SIGN(1) | SIGN(0) | SIGN(-1.1) | + |-----------+-----------+--------------| + | 1 | 0 | -1 | + +-----------+-----------+--------------+ + +### SIN + +The \`sin(x)\` function is a sine function, with \`x\` in radians. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** \`DOUBLE\` + +#### Example + + os> source=people | eval \`SIN(0)\` = SIN(0) | fields \`SIN(0)\` + fetched rows / total rows = 1/1 + +----------+ + | SIN(0) | + |----------| + | 0.0 | + +----------+ + +### SQRT + +The \`sqrt\` function calculates the square root of a non-negative value \`x\`. + +**Argument type:** \`INTEGER/LONG/FLOAT/DOUBLE\` + +**Return type:** (Non-negative) \`INTEGER/LONG/FLOAT/DOUBLE -> DOUBLE\` (Negative) \`INTEGER/LONG/FLOAT/DOUBLE -> NULL\` + +#### Example + + os> source=people | eval \`SQRT(4)\` = SQRT(4), \`SQRT(4.41)\` = SQRT(4.41) | fields \`SQRT(4)\`, \`SQRT(4.41)\` + fetched rows / total rows = 1/1 + +-----------+--------------+ + | SQRT(4) | SQRT(4.41) | + |-----------+--------------| + | 2.0 | 2.1 | + +-----------+--------------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/string.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/string.ts new file mode 100644 index 000000000000..1667b6bafd89 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/functions/string.ts @@ -0,0 +1,215 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const stringFunction = `## String +--- + +### String functions + +PPL functions use the search capabilities of the OpenSearch engine. However, these functions don't execute directly within the OpenSearch plugin's memory. Instead, they facilitate the global filtering of query results based on specific conditions, such as a \`WHERE\` or \`HAVING\` clause. + +The following sections describe the string functions. + +### CONCAT + +The \`CONCAT(str1, str2)\` function returns \`str1\` and \`str\` concatenated strings. + +**Argument type:** \`STRING, STRING\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`CONCAT('hello', 'world')\` = CONCAT('hello', 'world') | fields \`CONCAT('hello', 'world')\` + fetched rows / total rows = 1/1 + +----------------------------+ + | CONCAT('hello', 'world') | + |----------------------------| + | helloworld | + +----------------------------+ + +### CONCAT\_WS + +The \`CONCAT\_WS(sep, str1, str2)\` function concatenates two strings together, using \`sep\` as a separator between them. + +**Argument type:** \`STRING, STRING, STRING\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`CONCAT_WS(',', 'hello', 'world')\` = CONCAT_WS(',', 'hello', 'world') | fields \`CONCAT_WS(',', 'hello', 'world')\` + fetched rows / total rows = 1/1 + +------------------------------------+ + | CONCAT_WS(',', 'hello', 'world') | + |------------------------------------| + | hello,world | + +------------------------------------+ + +### LENGTH + +The \`length(str)\` function returns the length of a string, measured in number of bytes. + +**Function signature:** \`LENGTH(STRING) -> INTEGER\` + +**Argument type:** \`STRING\` + +**Return type:** \`INTEGER\` + +#### Example + + os> source=people | eval \`LENGTH('helloworld')\` = LENGTH('helloworld') | fields \`LENGTH('helloworld')\` + fetched rows / total rows = 1/1 + +------------------------+ + | LENGTH('helloworld') | + |------------------------| + | 10 | + +------------------------+ + +### LIKE + +The \`like(string, PATTERN)\` function returns \`true\` if the string matches the \`PATTERN\` value. The following two wildcards are commonly used with the \`like\` operator: + +- \`%\`: A percent sign represents zero, one, or multiple characters. +- \`_\`: An underscore represents a single character. + +#### Example + + os> source=people | eval \`LIKE('hello world', '_ello%')\` = LIKE('hello world', '_ello%') | fields \`LIKE('hello world', '_ello%')\` + fetched rows / total rows = 1/1 + +---------------------------------+ + | LIKE('hello world', '_ello%') | + |---------------------------------| + | True | + +---------------------------------+ + +### LOWER + +The \`lower(string)\` function converts a string to lowercase. + +**Argument type:** \`STRING\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`LOWER('helloworld')\` = LOWER('helloworld'), \`LOWER('HELLOWORLD')\` = LOWER('HELLOWORLD') | fields \`LOWER('helloworld')\`, \`LOWER('HELLOWORLD')\` + fetched rows / total rows = 1/1 + +-----------------------+-----------------------+ + | LOWER('helloworld') | LOWER('HELLOWORLD') | + |-----------------------+-----------------------| + | helloworld | helloworld | + +-----------------------+-----------------------+ + +### LTRIM + +The \`ltrim(str)\` function trims leading space characters from a string. + +**Argument type:** \`STRING\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`LTRIM(' hello')\` = LTRIM(' hello'), \`LTRIM('hello ')\` = LTRIM('hello ') | fields \`LTRIM(' hello')\`, \`LTRIM('hello ')\` + fetched rows / total rows = 1/1 + +---------------------+---------------------+ + | LTRIM(' hello') | LTRIM('hello ') | + |---------------------+---------------------| + | hello | hello | + +---------------------+---------------------+ + +### RIGHT + +The \`right(str, len)\` function returns the rightmost \`len\` characters from a \`str\` value. \`NULL\` is returned if any argument is null. + +**Argument type:** \`STRING, INTEGER\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`RIGHT('helloworld', 5)\` = RIGHT('helloworld', 5), \`RIGHT('HELLOWORLD', 0)\` = RIGHT('HELLOWORLD', 0) | fields \`RIGHT('helloworld', 5)\`, \`RIGHT('HELLOWORLD', 0)\` + fetched rows / total rows = 1/1 + +--------------------------+--------------------------+ + | RIGHT('helloworld', 5) | RIGHT('HELLOWORLD', 0) | + |--------------------------+--------------------------| + | world | | + +--------------------------+--------------------------+ + +### RTRIM + +The \`rtrim(str)\` function trims trailing space characters from a string. + +**Argument type:** \`STRING\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`RTRIM(' hello')\` = RTRIM(' hello'), \`RTRIM('hello ')\` = RTRIM('hello ') | fields \`RTRIM(' hello')\`, \`RTRIM('hello ')\` + fetched rows / total rows = 1/1 + +---------------------+---------------------+ + | RTRIM(' hello') | RTRIM('hello ') | + |---------------------+---------------------| + | hello | hello | + +---------------------+---------------------+ + +### SUBSTRING + +The \`substring(str, start)\` or \`substring(str, start, length)\`function returns a substring of the input string \`str\`. If \`length\` is not specified, the function returns the entire string from the \`start\` index. + +**Argument type:** \`STRING, INTEGER, INTEGER\` + +**Return type:** \`STRING\` + +**Synonyms:** \`SUBSTR\` + +#### Example + + os> source=people | eval \`SUBSTRING('helloworld', 5)\` = SUBSTRING('helloworld', 5), \`SUBSTRING('helloworld', 5, 3)\` = SUBSTRING('helloworld', 5, 3) | fields \`SUBSTRING('helloworld', 5)\`, \`SUBSTRING('helloworld', 5, 3)\` + fetched rows / total rows = 1/1 + +------------------------------+---------------------------------+ + | SUBSTRING('helloworld', 5) | SUBSTRING('helloworld', 5, 3) | + |------------------------------+---------------------------------| + | oworld | owo | + +------------------------------+---------------------------------+ + +### TRIM + +The \`trim\` function removes leading and trailing white space from a string. + +**Argument type:** \`STRING\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`TRIM(' hello')\` = TRIM(' hello'), \`TRIM('hello ')\` = TRIM('hello ') | fields \`TRIM(' hello')\`, \`TRIM('hello ')\` + fetched rows / total rows = 1/1 + +--------------------+--------------------+ + | TRIM(' hello') | TRIM('hello ') | + |--------------------+--------------------| + | hello | hello | + +--------------------+--------------------+ + +### UPPER + +The \`upper(string)\` function converts a string to uppercase. + +**Argument type:** \`STRING\` + +**Return type:** \`STRING\` + +#### Example + + os> source=people | eval \`UPPER('helloworld')\` = UPPER('helloworld'), \`UPPER('HELLOWORLD')\` = UPPER('HELLOWORLD') | fields \`UPPER('helloworld')\`, \`UPPER('HELLOWORLD')\` + fetched rows / total rows = 1/1 + +-----------------------+-----------------------+ + | UPPER('helloworld') | UPPER('HELLOWORLD') | + |-----------------------+-----------------------| + | HELLOWORLD | HELLOWORLD | + +-----------------------+-----------------------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/groups.tsx b/src/plugins/data/public/query/query_string/language_service/ppl_docs/groups.tsx new file mode 100644 index 000000000000..633def301493 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/groups.tsx @@ -0,0 +1,126 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + dedupCmd, + evalCmd, + fieldsCmd, + headCmd, + parseCmd, + rareCmd, + renameCmd, + searchCmd, + sortCmd, + statsCmd, + syntaxCmd, + topCmd, + whereCmd, +} from './commands'; +import { + mathFunction, + datetimeFunction, + stringFunction, + conditionFunction, + fullTextSearchFunction, +} from './functions'; +import { pplDatatypes, pplIdentifiers } from './language_structure'; + +export const Group1 = { + label: 'Commands', + options: [ + { + label: 'Syntax', + value: syntaxCmd, + }, + { + label: 'dedup', + value: dedupCmd, + }, + { + label: 'eval', + value: evalCmd, + }, + { + label: 'fields', + value: fieldsCmd, + }, + { + label: 'rename', + value: renameCmd, + }, + { + label: 'search', + value: searchCmd, + }, + { + label: 'sort', + value: sortCmd, + }, + { + label: 'stats', + value: statsCmd, + }, + { + label: 'where', + value: whereCmd, + }, + { + label: 'head', + value: headCmd, + }, + { + label: 'parse', + value: parseCmd, + }, + { + label: 'rare', + value: rareCmd, + }, + { + label: 'top', + value: topCmd, + }, + ], +}; + +export const Group2 = { + label: 'Functions', + options: [ + { + label: 'Math', + value: mathFunction, + }, + { + label: 'Date and Time', + value: datetimeFunction, + }, + { + label: 'String', + value: stringFunction, + }, + { + label: 'Condition', + value: conditionFunction, + }, + { + label: 'Full Text Search', + value: fullTextSearchFunction, + }, + ], +}; + +export const Group3 = { + label: 'Language Structure', + options: [ + { + label: 'Identifiers', + value: pplIdentifiers, + }, + { + label: 'Data Types', + value: pplDatatypes, + }, + ], +}; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/datatypes.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/datatypes.ts new file mode 100644 index 000000000000..7914bc628daf --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/datatypes.ts @@ -0,0 +1,326 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const pplDatatypes = `## Data Types +--- + +### Data types + +A data type defines a collection of data type values and a set of predefined operations for those values. PPL supports the following data types: + +.. hlist:: + :columns: 3 + + * array + * binary + * boolean + * byte + * date + * datetime + * double + * float + * geo\_point + * integer + * interval + * ip + * long + * short + * string + * struct + * text + * time + * timestamp + +### Data type mapping + +The following table is a reference guide for the mapping between an OpenSearch data type, a PPL data type, and a SQL data type. + +| OpenSearch type | PPL type | SQL type | +|-----------------|-----------|-----------| +| binary | binary | VARBINARY | +| boolean | boolean | BOOLEAN | +| byte | byte | TINYINT | +| date | timestamp | TIMESTAMP | +| double | double | DOUBLE | +| float | float | REAL | +| half\_float | float | FLOAT | +| integer | integer | INTEGER | +| ip | ip | VARCHAR | +| keyword | string | VARCHAR | +| long | long | BIGINT | +| nested | array | STRUCT | +| object | struct | STRUCT | +| scaled\_float | float | DOUBLE | +| short | byte | SMALLINT | +| text | text | VARCHAR | + +Some PPL types do not correspond to an OpenSearch type. To use functions that require date and time data types, data type conversion must be performed, as described in the following sections. + +### Numeric data types + +Numeric values ranging from -2147483648 to +2147483647 are recognized as +integers, with data type name \`INTEGER\`. For values that fall beyond the specified range, the \`LONG\` data type is assigned during parsing. + +### Date and time data types + +The data types \`date\` and \`time\` represent temporal values. The PPL plugin supports \`date\`, \`time\`, \`datetime\`, \`timestamp\`, and \`interval\`. By default, [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/) uses \`date\` for any date or time types. To integrate with PPL, each data type, excluding \`timestamp\`, contains temporal and time zone information. Use \`datetime\` functions to clarify the date and time types. Note that certain functions may have limitations on the input argument type. See the [Functions](functions.rst) section in this manual for more information. + +#### Date + +The \`date\` data type represents the calendar date, regardless of time zone. A specific date value represents a 24-hour period, but this period differs across time zones and may be subject to variations due to daylight saving time adjustments. Additionally, the date alone does not contain time-specific information. The date values range from '1000-01-01' to '9999-12-31'. + +| Type | Syntax | Range | +|----------|--------------|------------------------------| +| \`date\` | 'yyyy-MM-dd' | '0001-01-01' to '9999-12-31' | + +#### Time + +The \`time\` data type represents the time of day as displayed on a clock or watch, without specifying a particular time zone. It does not include any information about the calendar date. + +| Type | Syntax | Range | +|----------|--------------|------------------------------| +| \`time\` | 'hh:mm:ss\[.fraction\]' | '00:00:00.000000' to '23:59:59.999999' | + +#### Datetime + +The \`datetime\` data type represents a combination of \`date\` and \`time\`. The \`datetime\` data type does not contain time zone information. For an absolute time point that contains both datetime and time zone information, see the [Timestamp](#timestamp) section. + +See the [Conversion between date and time types](#conversion-between-date-and-time-types) section for information about the conversion rule for \`date\` or \`time\` to \`datetime\`. + +| Type | Syntax | Range | +|----------|--------|-------| +| \`datetime\` | 'yyyy-MM-dd hh:mm:ss\[.fraction\]' | '0001-01-01 00:00:00.000000' to '9999-12-31 23:59:59.999999' | + +#### Timestamp + +The \`timestamp\` data type represents absolute points in time, unaffected by time zones or conventions. The \`timestamp\` data type differs from other data types in its storage and retrieval behavior. When a timestamp is sorted, it is converted from Coordinated Universal Time (UTC) to the specified time zone. Conversely, when a timestamp is retrieved, it is converted back to UTC before being displayed or used in calculations. This ensures that the timestamp values remain consistent and comparable across different time zones. + +| Type | Syntax | Range | +|-----------|--------|-------| +| Timestamp | 'yyyy-MM-dd hh:mm:ss\[.fraction\]' | '0001-01-01 00:00:01.000000' UTC to '9999-12-31 23:59:59.999999' | + +#### Interval + +The \`interval\` data type represents a span of time encompassing a specified duration or period. + +| Type | Syntax | +|----------|--------------------| +| Interval | INTERVAL expr unit | + +The expression \`expr\` is configured to be repeatedly evaluated to produce a quantitative value. See the [Expressions](expressions.rst) section of this manual for more information. The unit represents the unit used to interpret the quantity, including MICROSECOND, SECOND, MINUTE, HOUR, DAY, WEEK, MONTH, QUARTER, and YEAR. The INTERVAL keyword and the unit specifier are not case sensitive. + +Intervals consist of two classes: day-time and year-week. Day-time intervals store days, hours, minutes, seconds, and microseconds. Year-week intervals store years, quarters, months, and weeks. Each type can only be compared to the same type. + +### Date and time conversion + +Date and time types, excluding \`interval\`, can be mutually converted, with some alteration of the value or some information loss, for example, when extracting the \`time\` value from a \`datetime\` value or converting a \`date\` value to a \`datetime\` value. The PPL plugin supports the following conversion rules for each of the types. + +#### \`date\` conversion + +- Because \`date\` does not contain time information, conversion to \`time\` returns a zero time value \`00:00:00\`. +- Converting from \`date\` to \`datetime\` sets the time value to \`00:00:00\` if \`time\` is not provided, for example, \`2020-08-17\` converts to \`2020-08-17 00:00:00\`. +- Converting to \`timestamp\` sets \`time\` to \`00:00:00\` and time zone (UTC by default), for example, \`2020-08-17\` converts to \`2020-08-17 00:00:00 UTC\`. + +#### \`time\` conversion + +- A \`time\` value does not have any date information, so it cannot be converted to other date and time types. + +#### \`datetime\` conversion + +- Converting from \`datetime\` to \`date\` extracts the date component from the \`datetime\` value, for example, \`2020-08-17 14:09:00\` converts to \`2020-08-08\`. +- Converting to \`time\` extracts the time component from the \`datetime\` value, for example, \`2020-08-17 14:09:00\` converts to \`14:09:00\`. +- Because \`datetime\` does not contain time zone information, conversion to \`timestamp\` sets the time zone to the session's time zone, for example, \`2020-08-17 14:09:00\`, with the system time zone set to UTC, for example, \`2020-08-17 14:09:00 UTC\`. + +#### \`timestamp\` conversion + +- Converting from \`timestamp\` to \`date\ extracts the \`date\` and \`time\` values. Converting from \`timestamp\` to \`datetime\` extracts the \`datetime\` value and retains the time zone information. For example, \`2020-08-17 14:09:00 UTC\` converts \`date\` and \`time\` to \`2020-08-17\` and \`14:09:00\` and \`datetime\` to \`2020-08-17 14:09:00\`. + +### String data types + +A \`string\` data type is a series of characters enclosed within single or double quotation marks that serves as a data type for storing text data. + +### Query struct data type + +In PPL, the \`struct\` data type corresponds to the [Object field type in +OpenSearch](https://opensearch.org/docs/latest/field-types/supported-field-types/object-fields/). The \`"."\` is used as the path selector for accessing the inner attribute of the struct data. + +#### Example 1: Struct to store population data + +The following example struct stores population data in an index containing the following fields: deep-nested object field \`city\`, object field of array value \`account\`, and nested field \`projects\`. + + { + "mappings": { + "properties": { + "city": { + "properties": { + "name": { + "type": "keyword" + }, + "location": { + "properties": { + "latitude": { + "type": "double" + } + } + } + } + }, + "account": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "projects": { + "type": "nested", + "properties": { + "name": { + "type": "keyword" + } + } + } + } + } + } + +#### Example 2: Struct to store employee data + +The following example struct stores employee data and includes a nested field: + + { + "mappings": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "projects": { + "type": "nested", + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "fielddata": true + }, + "started_year": { + "type": "long" + } + } + }, + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + +Result set: + + { + "employees_nested" : [ + { + "id" : 3, + "name" : "Bob Smith", + "title" : null, + "projects" : [ + { + "name" : "AWS Redshift Spectrum querying", + "started_year" : 1990 + }, + { + "name" : "AWS Redshift security", + "started_year" : 1999 + }, + { + "name" : "AWS Aurora security", + "started_year" : 2015 + } + ] + }, + { + "id" : 4, + "name" : "Susan Smith", + "title" : "Dev Mgr", + "projects" : [ ] + }, + { + "id" : 6, + "name" : "Jane Smith", + "title" : "Software Eng 2", + "projects" : [ + { + "name" : "AWS Redshift security", + "started_year" : 1998 + }, + { + "name" : "AWS Hello security", + "started_year" : 2015, + "address" : [ + { + "city" : "Dallas", + "state" : "TX" + } + ] + } + ] + } + ] + } + +#### Example 3: Select a struct inner attribute + +The following example PPL query shows how to fetch \`city\` (top level), \`city.name\` (second level), and \`city.location.latitude\` (deeper level) struct data from the results: + + os> source=people | fields city, city.name, city.location.latitude; + fetched rows / total rows = 1/1 + +-----------------------------------------------------+-------------+--------------------------+ + | city | city.name | city.location.latitude | + |-----------------------------------------------------+-------------+--------------------------| + | {'name': 'Seattle', 'location': {'latitude': 10.5}} | Seattle | 10.5 | + +-----------------------------------------------------+-------------+--------------------------+ + +#### Example 4: Group by a struct inner attribute + +The following example PPL query shows how to group by a struct inner attribute: + + os> source=people | stats count() by city.name; + fetched rows / total rows = 1/1 + +-----------+-------------+ + | count() | city.name | + |-----------+-------------| + | 1 | Seattle | + +-----------+-------------+ + +#### Example 5: Select an object field of an array value + +The following example PPL query shows how to select a deeper level for object fields of array values that return the first element in the array. In this example, the document's inner field \`accounts.id\` has three values instead of a tuple: + + os> source = people | fields accounts, accounts.id; + fetched rows / total rows = 1/1 + +------------+---------------+ + | accounts | accounts.id | + |------------+---------------| + | {'id': 1} | 1 | + +------------+---------------+ +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/identifiers.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/identifiers.ts new file mode 100644 index 000000000000..36288736366e --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/identifiers.ts @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const pplIdentifiers = `## Indentifiers +--- + +### Identifiers + +Identifiers are used for naming database objects, such as an index name, a field name, or a custom label. The two types of identifiers are _regular identifiers_ and _delimited identifiers_. + +#### Regular identifiers + +A regular identifier is a string of characters that starts with an ASCII letter (lowercase or uppercase). The subsequent characters can be a combination of letters, digits, or underscores (\`_\`). A regular identifier cannot be a reversed keyword, and white space or other special characters are not allowed. + +The following identifiers are supported by OpenSearch extensions: + +- **Identifiers prefixed by dot \`.\`:** This is called a hidden index. An example is \`.opensearch_dashboards\`. +- **Identifiers prefixed by the \`@\` symbol:** This is common in meta fields generated for data ingestion. +- **Identifiers with \`-\` in the middle:** This is common in index naming conventions with date information. +- **Identifiers with the \`*\` symbol:** This is common in wildcard matches in an index pattern. + +Index names with a date suffix separated by dashes or dots, such as \`cwl-2020.01.11\` or \`logs-7.0-2020.01.11\`, are common in data ingestion. Identifiers used as index names do not need to be enclosed in quotation marks. Additionally, wildcards within date patterns are accepted, enabling data retrieval across indexes covering different date ranges. For example, you can use \`logs-2020.1*\` to search in indexes for October, November, and December 2020. + +#### Example 1: Index pattern without quotes + +The following example PPL query uses an index pattern directly without quotes: + + os> source=accounts | fields account_number, firstname, lastname; + fetched rows / total rows = 4/4 + +------------------+-------------+------------+ + | account_number | firstname | lastname | + |------------------+-------------+------------| + | 1 | Amber | Duke | + | 6 | Hattie | Bond | + | 13 | Nanette | Bates | + | 18 | Dale | Adams | + +------------------+-------------+------------+ + +### Delimited identifiers + +A delimited identifier is an identifier enclosed in backticks \`\` that contains special characters not permitted in regular identifiers. This allows for the use of characters that would otherwise violate the naming rules for identifiers. + +#### Use cases + +Common use cases for delimited identifiers include the following: + +- Identifiers that coincide with reserved keywords. +- Identifiers that contain a dot \`.\` or a dash \`-\` need to be distinguished from regular identifiers with qualifiers. Enclosing such identifiers in backticks \`\` allows for the parser to differentiate them from qualified identifiers and enables date information within index names. +- Identifiers with special characters in index names. Note that OpenSearch permits the use of special characters, including Unicode characters. + +#### Example 2: Index name enclosed in backticks + +The following example PPL query uses an index name enclosed in backticks \`\`: + + os> source=\`accounts\` | fields \`account_number\`; + fetched rows / total rows = 4/4 + +------------------+ + | account_number | + |------------------| + | 1 | + | 6 | + | 13 | + | 18 | + +------------------+ + +### Case sensitivity + +Identifiers are case sensitive and must match what is stored in OpenSearch. For example, if you run \`source=Accounts\`, an error \`index not found\` occurs \` because the index name \`accounts\` is lowercase. +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/index.ts b/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/index.ts new file mode 100644 index 000000000000..75423e184c08 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/language_structure/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { pplDatatypes } from './datatypes'; +export { pplIdentifiers } from './identifiers'; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_docs/overview.tsx b/src/plugins/data/public/query/query_string/language_service/ppl_docs/overview.tsx new file mode 100644 index 000000000000..5c8d92e57df4 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_docs/overview.tsx @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const overview = `## Overview +--- +Piped Processing Language (PPL) is a query language that processes data in a sequential, step-by-step manner. PPL uses a set of commands, connected by pipes (|), to process data and return results (note that the requests and results are read-only in OpenSearch). + +You can query data in OpenSearch using [PPL](https://opensearch.org/docs/latest/search-plugins/sql/ppl/index/), [query domain-specific language (DSL)](https://opensearch.org/docs/latest/query-dsl/index/), or [SQL](https://opensearch.org/docs/latest/search-plugins/sql/sql/index/). + +PPL is the primary language used for observability tasks in OpenSearch. Developers, DevOps engineers, support engineers, site reliability engineers, and IT managers find it useful for exploring and discovering log, monitoring, and observability data. For example, you can use PPL to: + +- Find all log messages that contain a specific error code. +- Identify trends in your data over time. +- Group similar data points. +- Calculate statistics for your data. + +PPL is available in OpenSearch Dashboards and as a standalone command-line tool. Within OpenSearch Dashboards, you can use [Query Workbench](https://opensearch.org/docs/latest/dashboards/query-workbench/) to run on-demand PPL commands and view and save the results as both text and JSON. The [PPL command line interface (CLI)](https://opensearch.org/docs/latest/search-plugins/sql/cli/) is a standalone Python application that you can launch with the \`opensearchsql\` command and then run on-demand PPL commands and view and save the results as both text and JSON. + +Here is an example of a PPL query. This query retrieves the first and last names of all accounts for which the age is greater than 18. + +\`\`\` +source=accounts +| where age > 18 +| fields firstname, lastname +\`\`\` +`; diff --git a/src/plugins/data/public/query/query_string/language_service/ppl_reference_flyout.tsx b/src/plugins/data/public/query/query_string/language_service/ppl_reference_flyout.tsx new file mode 100644 index 000000000000..220dc1ffecc1 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/ppl_reference_flyout.tsx @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiComboBoxOptionOption, + EuiCompressedComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiLink, + EuiMarkdownFormat, + EuiSmallButton, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { FlyoutContainers } from './flyout_containers'; +import { Group1, Group2, Group3 } from './ppl_docs/groups'; +import { overview } from './ppl_docs/overview'; + +interface Props { + module: string; + onClose: () => void; +} + +export const PPLReferenceFlyout = ({ module, onClose }: Props) => { + const allOptionsStatic = [{ label: 'Overview', value: overview }, Group1, Group2, Group3]; + const defaultOption = [allOptionsStatic[0]]; + const [selectedOptions, setSelected] = useState(defaultOption); + const [flyoutContent, setFlyoutContent] = useState( + {defaultOption[0].value} + ); + + const onChange = (SelectedOptions: any) => { + setSelected(SelectedOptions); + + const newContent = SelectedOptions.map((option: EuiComboBoxOptionOption) => ( + {option.value} + )); + setFlyoutContent(newContent); + }; + + const flyoutHeader = ( + + +

OpenSearch PPL Reference Manual

+
+
+ ); + + const PPL_DOCUMENTATION_URL = 'https://opensearch.org/docs/latest/search-plugins/sql/ppl/index'; + + const flyoutBody = ( + + + + + + + + + Learn More + + + + + + {flyoutContent} + + ); + + const flyoutFooter = ( + + + + Close + + + + ); + + return ( + + ); +}; diff --git a/src/plugins/data/public/query/query_string/language_service/recent_query.tsx b/src/plugins/data/public/query/query_string/language_service/recent_query.tsx new file mode 100644 index 000000000000..d058cb5c692a --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/recent_query.tsx @@ -0,0 +1,169 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './_recent_query.scss'; + +import { + EuiBasicTable, + EuiButtonEmpty, + EuiButtonIcon, + EuiCopy, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import moment from 'moment'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { Query, TimeRange } from 'src/plugins/data/common'; +import { QueryStringContract } from '../query_string_manager'; + +// TODO: Need to confirm this number +export const MAX_RECENT_QUERY_SIZE = 10; + +interface RecentQueryItem { + query: Query; + time: number; + timeRange?: TimeRange; +} + +export function RecentQuery(props: { + queryString: QueryStringContract; + query: Query; + onClickRecentQuery: (query: Query, timeRange?: TimeRange) => void; +}) { + const [recentQueries, setRecentQueries] = useState( + props.queryString.getQueryHistory() + ); + const [isPopoverOpen, setPopover] = useState(false); + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const clearHistory = useCallback(() => { + props.queryString?.clearQueryHistory(); + setRecentQueries(props.queryString?.getQueryHistory()); + }, [props.queryString]); + + const clear = () => { + clearHistory(); + }; + + useEffect(() => { + const done = props.queryString.changeQueryHistory(setRecentQueries); + return () => done(); + }, [props.queryString]); + + const getRowProps = (item: any) => { + const { id } = item; + return { + 'data-test-subj': `row-${id}`, + className: 'customRowClass', + onClick: () => {}, + }; + }; + + const getCellProps = (item: any, column: any) => { + const { id } = item; + const { field } = column; + return { + className: 'customCellClass', + 'data-test-subj': `cell-${id}-${field}`, + textOnly: true, + }; + }; + + const actions = [ + { + name: 'Run', + description: 'Run recent query', + icon: 'play', + type: 'icon', + onClick: (item) => { + props.onClickRecentQuery(recentQueries[item.id].query, recentQueries[item.id].timeRange); + setPopover(false); + }, + 'data-test-subj': 'action-run', + }, + { + render: (item) => { + return ( + + {(copy) => ( + + )} + + ); + }, + }, + ]; + + const tableColumns = [ + { + field: 'query', + name: 'Recent query', + }, + { + field: 'language', + name: 'Language', + }, + { + field: 'time', + name: 'Last run', + }, + { name: 'Actions', actions }, + ]; + + const recentQueryItems = recentQueries + .filter((item, idx) => idx < MAX_RECENT_QUERY_SIZE) + .map((query, idx) => { + const date = moment(query.time); + + const formattedDate = date.format('MMM D, YYYY HH:mm:ss'); + + let queryLanguage = query.query.language; + if (queryLanguage === 'kuery') { + queryLanguage = 'DQL'; + } + + const tableItem = { + id: idx, + query: query.query.query, + timeRange: query.timeRange, + language: queryLanguage, + time: formattedDate, + }; + + return tableItem; + }); + + return ( + + + {'Recent queries'} + + + } + isOpen={isPopoverOpen} + closePopover={() => setPopover(false)} + panelPaddingSize="none" + anchorPosition={'downRight'} + > + + + ); +} diff --git a/src/plugins/data/public/query/query_string/language_service/sql_reference_flyout.tsx b/src/plugins/data/public/query/query_string/language_service/sql_reference_flyout.tsx new file mode 100644 index 000000000000..40b849fb72d7 --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/sql_reference_flyout.tsx @@ -0,0 +1,61 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiComboBoxOptionOption, + EuiCompressedComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiLink, + EuiMarkdownFormat, + EuiSmallButton, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { FlyoutContainers } from './flyout_containers'; +import { Group1, Group2, Group3 } from './ppl_docs/groups'; +import { overview } from './ppl_docs/overview'; + +interface Props { + module: string; + onClose: () => void; +} + +export const SQLReferenceFlyout = ({ module, onClose }: Props) => { + const flyoutHeader = ( + + +

OpenSearch SQL Reference Manual

+
+
+ ); + + const flyoutBody = null; + + const flyoutFooter = ( + + + + Close + + + + ); + + return ( + + ); +}; diff --git a/src/plugins/data/public/query/query_string/query_history.ts b/src/plugins/data/public/query/query_string/query_history.ts index 80dfa4b5d560..04e2285add1c 100644 --- a/src/plugins/data/public/query/query_string/query_history.ts +++ b/src/plugins/data/public/query/query_string/query_history.ts @@ -35,7 +35,7 @@ export class QueryHistory { return () => subscription.unsubscribe(); } - addQueryToHistory(dataset: Dataset, query: Query, dateRange?: TimeRange) { + addQueryToHistory(query: Query, dateRange?: TimeRange) { const keys = this.getHistoryKeys(); keys.splice(0, 500); // only maintain most recent X; keys.forEach((key) => { @@ -45,7 +45,6 @@ export class QueryHistory { const timestamp = new Date().getTime(); const k = 'query_' + timestamp; this.storage.set(k, { - dataset, time: timestamp, query, dateRange, 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 6bbc5706b64b..754b444435c0 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 @@ -112,9 +112,9 @@ export class QueryStringManager { }; // Todo: update this function to use the Query object when it is udpated, Query object should include time range and dataset - public addToQueryHistory(dataSet: Dataset, query: Query, timeRange?: TimeRange) { + public addToQueryHistory(query: Query, timeRange?: TimeRange) { if (query.query) { - this.queryHistory.addQueryToHistory(dataSet, query, timeRange); + this.queryHistory.addQueryToHistory(query, timeRange); } } diff --git a/src/plugins/data/public/ui/filter_bar/filter_options.tsx b/src/plugins/data/public/ui/filter_bar/filter_options.tsx index 3edd47e3fddc..131d317ecbc5 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_options.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_options.tsx @@ -55,6 +55,7 @@ import { unpinFilter, UI_SETTINGS, IIndexPattern, + isQueryStringFilter, } from '../../../common'; import { FilterEditor } from './filter_editor'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; @@ -75,6 +76,7 @@ interface Props { onFiltersUpdated?: (filters: Filter[]) => void; loadedSavedQuery?: SavedQuery; useSaveQueryMenu: boolean; + isQueryEditorControl: boolean; } const maxFilterWidth = 600; @@ -371,7 +373,10 @@ const FilterOptionsUI = (props: Props) => { className="osdSavedQueryManagement__popoverButton" title={label} > - + ); @@ -398,6 +403,25 @@ const FilterOptionsUI = (props: Props) => { ); + if (props.isQueryEditorControl) { + return ( + + {saveQueryPanel} + + ); + } + return ( void; editorDidMount: (editor: any) => void; footerItems?: { - start?: Array; - end?: Array; + start?: any[]; + end?: any[]; }; headerRef?: React.RefObject; provideCompletionItems: monaco.languages.CompletionItemProvider['provideCompletionItems']; @@ -72,28 +67,23 @@ export const DefaultInput: React.FC = ({ }} /> {footerItems && ( -
- {footerItems.start?.map((item, index) => ( - - ))} -
- {footerItems.end?.map((item, index) => ( - - ))} -
+ + + {footerItems.start?.map((item) => ( + + {item} + + ))} + + + {footerItems.end?.map((item) => ( + {item} + ))} + + )}
); }; -const FooterItem: React.FC<{ item: FooterItem | string }> = ({ item }) => { - const color = typeof item === 'string' ? ('subdued' as const) : item.color; - const text = typeof item === 'string' ? item : item.text; - return ( - - {text} - - ); -}; - export const createDefaultEditor = createEditor(SingleLineInput, null, DefaultInput); diff --git a/src/plugins/data/public/ui/query_editor/editors/shared.tsx b/src/plugins/data/public/ui/query_editor/editors/shared.tsx index 23b46c22a48c..ad9af1e87e19 100644 --- a/src/plugins/data/public/ui/query_editor/editors/shared.tsx +++ b/src/plugins/data/public/ui/query_editor/editors/shared.tsx @@ -4,6 +4,7 @@ */ import React from 'react'; +import { EuiCompressedFieldText } from '@elastic/eui'; import { monaco } from '@osd/monaco'; import { CodeEditor } from '../../../../../opensearch_dashboards_react/public'; @@ -13,6 +14,7 @@ interface SingleLineInputProps extends React.JSX.IntrinsicAttributes { onChange: (value: string) => void; editorDidMount: (editor: any) => void; provideCompletionItems: monaco.languages.CompletionItemProvider['provideCompletionItems']; + prepend?: React.ComponentProps['prepend']; } type CollapsedComponent = React.ComponentType; @@ -63,51 +65,55 @@ export const SingleLineInput: React.FC = ({ onChange, editorDidMount, provideCompletionItems, + prepend, }) => ( -
- + {prepend} +
+ + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + cursorStyle: 'line', + wordBasedSuggestions: false, + }} + suggestionProvider={{ + provideCompletionItems, + }} + languageConfiguration={{ + autoClosingPairs: [ + { + open: '(', + close: ')', + }, + { + open: '"', + close: '"', + }, + ], + }} + /> +
); diff --git a/src/plugins/data/public/ui/query_editor/query_editor.tsx b/src/plugins/data/public/ui/query_editor/query_editor.tsx index ccc616911ed8..619184883220 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PopoverAnchorPosition } from '@elastic/eui'; +import { EuiCompressedFieldText, EuiText, PopoverAnchorPosition } from '@elastic/eui'; import classNames from 'classnames'; import { isEqual } from 'lodash'; import React, { Component, createRef, RefObject } from 'react'; @@ -15,9 +15,11 @@ import { fromUser, getQueryLog, PersistedLog, toUser } from '../../query'; import { SuggestionsListSize } from '../typeahead/suggestions_component'; import { QueryLanguageSelector } from './language_selector'; import { QueryEditorExtensions } from './query_editor_extensions'; -import { QueryEditorBtnCollapse } from './query_editor_btn_collapse'; import { getQueryService, getIndexPatterns } from '../../services'; import { DatasetSelector } from '../dataset_selector'; +import { QueryControls } from '../../query/query_string/language_service/get_query_control_links'; +import { RecentQuery } from '../../query/query_string/language_service/recent_query'; +import { DefaultInputProps } from './editors'; const LANGUAGE_ID_SQL = 'SQL'; monaco.languages.register({ id: LANGUAGE_ID_SQL }); @@ -46,6 +48,8 @@ export interface QueryEditorProps { bannerClassName?: string; footerClassName?: string; filterBar?: any; + prepend?: React.ComponentProps['prepend']; + savedQueryManagement?: any; } interface Props extends QueryEditorProps { @@ -157,6 +161,10 @@ export default class QueryEditorUI extends Component { }); }; + private onClickRecentQuery = (query: Query, timeRange?: TimeRange) => { + this.onSubmit(query, timeRange); + }; + private onInputChange = (value: string) => { this.onQueryStringChange(value); @@ -285,6 +293,21 @@ export default class QueryEditorUI extends Component { }; }; + public onToggleCollapse = () => { + this.setState({ isCollapsed: !this.state.isCollapsed }); + }; + + private renderQueryControls = () => { + return ( + + ); + }; + public render() { const className = classNames(this.props.className); @@ -305,7 +328,7 @@ export default class QueryEditorUI extends Component { value: this.getQueryString(), }; - const defaultInputProps = { + const defaultInputProps: DefaultInputProps = { ...baseInputProps, onChange: this.onInputChange, editorDidMount: (editor: monaco.editor.IStandaloneCodeEditor) => { @@ -315,8 +338,19 @@ export default class QueryEditorUI extends Component { }, footerItems: { start: [ - `${this.state.lineCount} ${this.state.lineCount === 1 ? 'line' : 'lines'}`, - this.props.query.dataset?.timeFieldName || '', + + {`${this.state.lineCount} ${this.state.lineCount === 1 ? 'line' : 'lines'}`} + , + + {this.props.query.dataset?.timeFieldName || ''} + , + ], + end: [ + , ], }, provideCompletionItems: this.provideCompletionItems, @@ -350,6 +384,7 @@ export default class QueryEditorUI extends Component { }; }, provideCompletionItems: this.provideCompletionItems, + prepend: this.props.prepend, }; const languageEditorFunc = this.languageManager.getLanguage(this.props.query.language)!.editor; @@ -374,10 +409,6 @@ export default class QueryEditorUI extends Component { className={classNames('osdQueryEditor__banner', this.props.bannerClassName)} />
- this.setState({ isCollapsed: !this.state.isCollapsed })} - isCollapsed={!this.state.isCollapsed} - />
{this.state.isCollapsed @@ -385,7 +416,7 @@ export default class QueryEditorUI extends Component { : languageEditor.TopBar.Expanded && languageEditor.TopBar.Expanded()}
{languageSelector} - {this.props.queryActions} +
{this.renderQueryControls()}
void; - isCollapsed: boolean; -} - -export function QueryEditorBtnCollapse({ onClick, isCollapsed }: Props) { - const label = i18n.translate('queryEditor.collapse', { - defaultMessage: 'Toggle query editor', - }); - return ( -
- - - -
- ); -} diff --git a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx index 0c15a551a3f8..a5248d3d61cb 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx @@ -53,6 +53,7 @@ export interface QueryEditorTopRowProps { timeHistory?: TimeHistoryContract; indicateNoData?: boolean; datePickerRef?: React.RefObject; + savedQueryManagement?: any; } // Needed for React.lazy @@ -174,7 +175,7 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { ); 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 891cf70379b2..b986a1e11ddd 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -234,19 +234,22 @@ class SearchBarUI extends Component { return this.props.showQueryBar && (showDatePicker || showQueryInput); } - private shouldRenderFilterBar(isEnhancementsEnabledOverride: boolean) { - const language = this.queryStringManager - .getLanguageService() - .getLanguage(this.state.query?.language!); - const isFilterable = language?.fields?.filterable !== false; // Render if undefined or true + private isQueryLanguageFilterable() { + return ( + this.queryStringManager.getLanguageService().getLanguage(this.state.query?.language!)?.fields + ?.filterable ?? true // Render if undefined or true + ); + } + private shouldRenderFilterBar(isEnhancementsEnabledOverride: boolean) { return ( this.props.showFilterBar && this.props.filters && (!this.useNewHeader || this.props.filters.length > 0) && this.props.indexPatterns && compact(this.props.indexPatterns).length > 0 && - (!isEnhancementsEnabledOverride || (isEnhancementsEnabledOverride && isFilterable)) + (!isEnhancementsEnabledOverride || + (isEnhancementsEnabledOverride && this.isQueryLanguageFilterable)) ); } @@ -380,10 +383,9 @@ class SearchBarUI extends Component { } } ); - const dataset = this.queryStringManager.getQuery().dataset; - if (dataset && queryAndDateRange.query) { + + if (queryAndDateRange.query) { this.queryStringManager.addToQueryHistory( - dataset, queryAndDateRange.query, queryAndDateRange.dateRange ); @@ -427,7 +429,10 @@ class SearchBarUI extends Component { .getLanguage(this.state.query?.language!) ?.editorSupportedAppNames?.includes(this.services.appName); - const searchBarMenu = (useSaveQueryMenu: boolean = false) => { + const searchBarMenu = ( + useSaveQueryMenu: boolean = false, + isQueryEditorControl: boolean = false + ) => { return ( this.state.query && this.props.onClearSavedQuery && ( @@ -444,6 +449,7 @@ class SearchBarUI extends Component { savedQueryService={this.savedQueryService} onClearSavedQuery={this.props.onClearSavedQuery} useSaveQueryMenu={useSaveQueryMenu} + isQueryEditorControl={isQueryEditorControl} /> ) ); @@ -480,6 +486,7 @@ class SearchBarUI extends Component { } let queryBar; + if (this.shouldRenderQueryBar(isEnhancementsEnabledOverride)) { queryBar = ( { onSubmit={this.onQueryBarSubmit} indexPatterns={this.props.indexPatterns} isLoading={this.props.isLoading} - prepend={this.props.showFilterBar ? searchBarMenu(!this.useNewHeader) : undefined} + prepend={this.props.showFilterBar ? searchBarMenu(!this.useNewHeader, false) : undefined} showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} @@ -521,7 +528,7 @@ class SearchBarUI extends Component { onSubmit={this.onQueryBarSubmit} indexPatterns={this.props.indexPatterns} isLoading={this.props.isLoading} - prepend={this.props.showFilterBar ? searchBarMenu(!this.useNewHeader) : undefined} + prepend={this.isQueryLanguageFilterable() ? searchBarMenu() : undefined} showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} @@ -540,6 +547,7 @@ class SearchBarUI extends Component { dataTestSubj={this.props.dataTestSubj} indicateNoData={this.props.indicateNoData} datePickerRef={this.props.datePickerRef} + savedQueryManagement={searchBarMenu(false, true)} /> ); } diff --git a/src/plugins/data_explorer/public/plugin.ts b/src/plugins/data_explorer/public/plugin.ts index 3b953567fbe8..d2c8da53a697 100644 --- a/src/plugins/data_explorer/public/plugin.ts +++ b/src/plugins/data_explorer/public/plugin.ts @@ -13,6 +13,7 @@ import { AppNavLinkStatus, ScopedHistory, AppUpdater, + DEFAULT_NAV_GROUPS, } from '../../../core/public'; import { DataExplorerPluginSetup, @@ -123,6 +124,41 @@ export class DataExplorerPlugin }, }); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ + { + id: PLUGIN_ID, + order: 301, // The nav link should be put behind discover + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ + { + id: PLUGIN_ID, + order: 301, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.essentials, [ + { + id: PLUGIN_ID, + order: 201, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ + { + id: PLUGIN_ID, + order: 201, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ + { + id: PLUGIN_ID, + order: 201, + }, + ]); + return { ...this.viewService.setup(), }; diff --git a/src/plugins/data_source/server/routes/fetch_data_source_metadata.test.ts b/src/plugins/data_source/server/routes/fetch_data_source_metadata.test.ts index 40d5c8f8ab02..2c9bb9811cb3 100644 --- a/src/plugins/data_source/server/routes/fetch_data_source_metadata.test.ts +++ b/src/plugins/data_source/server/routes/fetch_data_source_metadata.test.ts @@ -16,6 +16,8 @@ import { registerFetchDataSourceMetaDataRoute } from './fetch_data_source_metada import { AuthType } from '../../common/data_sources'; // eslint-disable-next-line @osd/eslint/no-restricted-paths import { opensearchClientMock } from '../../../../../src/core/server/opensearch/client/mocks'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { dynamicConfigServiceMock } from '../../../../../src/core/server/config'; type SetupServerReturn = UnwrapPromise>; @@ -31,6 +33,7 @@ describe(`Fetch DataSource MetaData ${URL}`, () => { let dataSourceClient: ReturnType; let dataSourceServiceSetupMock: DataSourceServiceSetup; let authRegistryPromiseMock: Promise; + const dynamicConfigServiceStart = dynamicConfigServiceMock.createInternalStartContract(); const dataSourceAttr = { endpoint: 'https://test.com', auth: { @@ -186,7 +189,7 @@ describe(`Fetch DataSource MetaData ${URL}`, () => { customApiSchemaRegistryPromise ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); }); afterEach(async () => { diff --git a/src/plugins/data_source/server/routes/test_connection.test.ts b/src/plugins/data_source/server/routes/test_connection.test.ts index cbe35ef6562a..65775c5e3334 100644 --- a/src/plugins/data_source/server/routes/test_connection.test.ts +++ b/src/plugins/data_source/server/routes/test_connection.test.ts @@ -16,6 +16,8 @@ import { registerTestConnectionRoute } from './test_connection'; import { AuthType } from '../../common/data_sources'; // eslint-disable-next-line @osd/eslint/no-restricted-paths import { opensearchClientMock } from '../../../../../src/core/server/opensearch/client/mocks'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { dynamicConfigServiceMock } from '../../../../../src/core/server/config'; type SetupServerReturn = UnwrapPromise>; @@ -41,6 +43,7 @@ describe(`Test connection ${URL}`, () => { }, }, }; + const dynamicConfigServiceStart = dynamicConfigServiceMock.createInternalStartContract(); const dataSourceAttrMissingCredentialForNoAuth = { endpoint: 'https://test.com', @@ -175,7 +178,7 @@ describe(`Test connection ${URL}`, () => { customApiSchemaRegistryPromise ); - await server.start(); + await server.start({ dynamicConfigService: dynamicConfigServiceStart }); }); afterEach(async () => { diff --git a/src/plugins/data_source_management/opensearch_dashboards.json b/src/plugins/data_source_management/opensearch_dashboards.json index fb37a16eea0f..86f5d0b5d11f 100644 --- a/src/plugins/data_source_management/opensearch_dashboards.json +++ b/src/plugins/data_source_management/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["management", "indexPatternManagement"], + "requiredPlugins": ["navigation", "management", "indexPatternManagement"], "optionalPlugins": ["dataSource"], "requiredBundles": ["opensearchDashboardsReact", "dataSource", "opensearchDashboardsUtils"], "extraPublicDirs": ["public/components/utils"], diff --git a/src/plugins/data_source_management/public/components/breadcrumbs.ts b/src/plugins/data_source_management/public/components/breadcrumbs.ts index e1ac30bddb88..0980e931d5fb 100644 --- a/src/plugins/data_source_management/public/components/breadcrumbs.ts +++ b/src/plugins/data_source_management/public/components/breadcrumbs.ts @@ -28,14 +28,14 @@ export function getCreateBreadcrumbs() { }, ]; } -export function getCreateOpenSearchDataSourceBreadcrumbs() { +export function getCreateOpenSearchDataSourceBreadcrumbs(useNewUX: boolean) { return [ ...getCreateBreadcrumbs(), { text: i18n.translate( 'dataSourcesManagement.dataSources.createOpenSearchDataSourceBreadcrumbs', { - defaultMessage: 'Open Search', + defaultMessage: useNewUX ? 'Connect OpenSearch Cluster' : 'Open Search', } ), href: `/configure/OpenSearch`, @@ -43,14 +43,14 @@ export function getCreateOpenSearchDataSourceBreadcrumbs() { ]; } -export function getCreateAmazonS3DataSourceBreadcrumbs() { +export function getCreateAmazonS3DataSourceBreadcrumbs(useNewUX: boolean) { return [ ...getCreateBreadcrumbs(), { text: i18n.translate( 'dataSourcesManagement.dataSources.createAmazonS3DataSourceBreadcrumbs', { - defaultMessage: 'Amazon S3', + defaultMessage: useNewUX ? 'Connect Amazon S3' : 'Amazon S3', } ), href: `/configure/AmazonS3AWSGlue`, @@ -58,14 +58,14 @@ export function getCreateAmazonS3DataSourceBreadcrumbs() { ]; } -export function getCreatePrometheusDataSourceBreadcrumbs() { +export function getCreatePrometheusDataSourceBreadcrumbs(useNewUX: boolean) { return [ ...getCreateBreadcrumbs(), { text: i18n.translate( 'dataSourcesManagement.dataSources.createPrometheusDataSourceBreadcrumbs', { - defaultMessage: 'Prometheus', + defaultMessage: useNewUX ? 'Connect Prometheus' : 'Prometheus', } ), href: `/configure/Prometheus`, diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx index 7fec74bdc0f9..f2eb7778e8e7 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx @@ -23,6 +23,8 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; +import { ApplicationStart } from 'opensearch-dashboards/public'; import { AuthenticationMethodRegistry } from '../../../../auth_registry'; import { SigV4Content, SigV4ServiceName } from '../../../../../../data_source/common/data_sources'; import { @@ -47,6 +49,9 @@ import { } from '../../../utils'; export interface CreateDataSourceProps { + useNewUX: boolean; + navigation: NavigationPublicPluginStart; + application: ApplicationStart; existingDatasourceNamesList: string[]; handleSubmit: (formValues: DataSourceAttributes) => void; handleTestConnection: (formValues: DataSourceAttributes) => void; @@ -367,21 +372,41 @@ export class CreateDataSourceForm extends React.Component< return null; }; + description = [ + { + renderComponent: ( + + + + ), + }, + ]; + /* Render methods */ /* Render header*/ renderHeader = () => { - return
; + return this.props.useNewUX ? ( + + ) : ( +
+ ); }; /* Render Section header*/ renderSectionHeader = (i18nId: string, defaultMessage: string) => { return ( <> - -

+ +

-

+

); @@ -554,7 +579,6 @@ export class CreateDataSourceForm extends React.Component< <> {this.renderHeader()} - {/* Endpoint section */} {this.renderSectionHeader( @@ -749,7 +773,7 @@ export class CreateDataSourceForm extends React.Component< > diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/header/header.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/header/header.tsx index 75eda9af9809..c6caa900b436 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/header/header.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/header/header.tsx @@ -45,6 +45,7 @@ export const Header = () => {

+
diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx index 0be281a20a38..e589d4fee72a 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx @@ -37,15 +37,18 @@ export const CreateDataSourceWizard: React.FunctionComponent().services; /* State Variables */ const [existingDatasourceNamesList, setExistingDatasourceNamesList] = useState([]); const [isLoading, setIsLoading] = useState(false); + const useNewUX = uiSettings.get('home:useNewHomePage'); /* Set breadcrumb */ useEffectOnce(() => { - setBreadcrumbs(getCreateOpenSearchDataSourceBreadcrumbs()); + setBreadcrumbs(getCreateOpenSearchDataSourceBreadcrumbs(useNewUX)); getExistingDataSourceNames(); }); @@ -128,6 +131,9 @@ export const CreateDataSourceWizard: React.FunctionComponent props.history.push('/create')} diff --git a/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel.test.tsx b/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel.test.tsx index 5884dadc5876..235446ffbfea 100644 --- a/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel.test.tsx @@ -10,8 +10,8 @@ import { CreateDataSourcePanelHeader } from './create_data_source_panel_header'; import { CreateDataSourceCardView } from './create_data_source_card_view'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { getCreateBreadcrumbs } from '../breadcrumbs'; -import { DataSourceManagementContext } from '../../types'; import { RouteComponentProps } from 'react-router-dom'; +import { navigationPluginMock } from 'src/plugins/navigation/public/mocks'; jest.mock('../../../../opensearch_dashboards_react/public'); jest.mock('../breadcrumbs'); @@ -34,6 +34,7 @@ describe('CreateDataSourcePanel', () => { }, }, uiSettings: {}, + navigation: navigationPluginMock.createStartContract(), }, }; diff --git a/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel.tsx b/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel.tsx index 7e6761d51254..8228c82e4ca1 100644 --- a/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel.tsx +++ b/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel.tsx @@ -3,9 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiPanel, EuiText } from '@elastic/eui'; import React, { useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { FormattedMessage } from '@osd/i18n/react'; import { CreateDataSourcePanelHeader } from './create_data_source_panel_header'; import { CreateDataSourceCardView } from './create_data_source_card_view'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; @@ -14,24 +15,56 @@ import { DataSourceManagementContext } from '../../types'; interface CreateDataSourcePanelProps extends RouteComponentProps { featureFlagStatus: boolean; + useNewUX: boolean; } export const CreateDataSourcePanel: React.FC = ({ featureFlagStatus, + useNewUX, ...props }) => { const { chrome, + application, setBreadcrumbs, notifications: { toasts }, uiSettings, + navigation, } = useOpenSearchDashboards().services; useEffect(() => { setBreadcrumbs(getCreateBreadcrumbs()); }, [setBreadcrumbs]); - return ( + const { HeaderControl } = navigation.ui; + const description = [ + { + renderComponent: ( + + + + ), + }, + ]; + + return useNewUX ? ( + <> + + + + + + + + + + ) : ( diff --git a/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_home_panel.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_home_panel.test.tsx.snap index e4144646e0b0..cda3acc64031 100644 --- a/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_home_panel.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_home_panel.test.tsx.snap @@ -61,9 +61,6 @@ exports[`DataSourceHomePanel renders correctly 1`] = ` - { savedObjects: {}, uiSettings: {}, application: { capabilities: { dataSource: { canManage: true } } }, + docLinks: { + links: { + opensearchDashboards: { + dataSource: { + guide: 'https://opensearch.org/docs/latest/dashboards/discover/multi-data-sources/', + }, + }, + }, + }, + navigation: navigationPluginMock.createStartContract(), }, }; diff --git a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.tsx b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.tsx index 3291885a8b60..dfb3fc0ae188 100644 --- a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.tsx +++ b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.tsx @@ -13,7 +13,12 @@ import { EuiTab, EuiPageHeader, EuiPanel, + EuiButtonGroup, + EuiText, + EuiLink, } from '@elastic/eui'; +import { TopNavControlButtonData, TopNavControlComponentData } from 'src/plugins/navigation/public'; +import { FormattedMessage } from '@osd/i18n/react'; import { DataSourceHeader } from './data_source_page_header'; import { DataSourceTableWithRouter } from '../data_source_table/data_source_table'; import { ManageDirectQueryDataConnectionsTable } from '../direct_query_data_sources_components/direct_query_data_connection/manage_direct_query_data_connections_table'; @@ -24,10 +29,12 @@ import { DataSourceManagementContext } from '../../types'; interface DataSourceHomePanelProps extends RouteComponentProps { featureFlagStatus: boolean; + useNewUX: boolean; } export const DataSourceHomePanel: React.FC = ({ featureFlagStatus, + useNewUX, ...props }) => { const { @@ -37,6 +44,8 @@ export const DataSourceHomePanel: React.FC = ({ savedObjects, uiSettings, application, + docLinks, + navigation, } = useOpenSearchDashboards().services; const defaultTabId = featureFlagStatus @@ -44,6 +53,7 @@ export const DataSourceHomePanel: React.FC = ({ : 'manageDirectQueryDataSources'; const [selectedTabId, setSelectedTabId] = useState(defaultTabId); const canManageDataSource = !!application.capabilities?.dataSource?.canManage; + const { HeaderControl } = navigation.ui; useEffect(() => { setBreadcrumbs(getListBreadcrumbs()); @@ -53,6 +63,56 @@ export const DataSourceHomePanel: React.FC = ({ setSelectedTabId(id); }; + const description = [ + { + renderComponent: ( + + + + Learn more + + + ), + }, + ]; + + const createDataSourceButton = [ + { + id: 'Create data source', + label: 'Create data source connection', + testId: 'createDataSourceButton', + run: () => props.history.push('/create'), + fill: true, + iconType: 'plus', + controlType: 'button', + } as TopNavControlButtonData, + ]; + + const connectionTypeButton = [ + { + renderComponent: ( + onSelectedTabChanged(id)} + /> + ), + } as TopNavControlComponentData, + ]; + const tabs = [ ...(featureFlagStatus ? [ @@ -82,27 +142,48 @@ export const DataSourceHomePanel: React.FC = ({ return ( + {useNewUX && ( + <> + + {canManageDataSource && ( + + )} + + + )} + {!useNewUX && ( + <> + + + + + + + {canManageDataSource ? ( + + + + ) : null} + + + + + + {renderTabs()} + + + )} - - - - - - {canManageDataSource ? ( - - - - ) : null} - - - - - - {renderTabs()} - - - {selectedTabId === 'manageOpensearchDataSources' && featureFlagStatus && ( )} diff --git a/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap index 3f12831b546b..9dc785b13b4e 100644 --- a/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap @@ -369,6 +369,7 @@ exports[`DataSourceTable should get datasources successful should render normall }, }, }, + "compressed": true, "toolsRight": @@ -416,6 +417,7 @@ exports[`DataSourceTable should get datasources successful should render normall }, } } + compressed={true} onChange={[Function]} toolsRight={
@@ -504,7 +507,7 @@ exports[`DataSourceTable should get datasources successful should render normall className="euiFormControlLayoutIcons" >