diff --git a/.github/workflows/unit-tests-workflow.yml b/.github/workflows/unit-tests-workflow.yml index db69d555..ac66e0f2 100644 --- a/.github/workflows/unit-tests-workflow.yml +++ b/.github/workflows/unit-tests-workflow.yml @@ -9,9 +9,22 @@ on: env: OPENSEARCH_DASHBOARDS_VERSION: '2.x' jobs: + Get-CI-Image-Tag: + uses: opensearch-project/opensearch-build/.github/workflows/get-ci-image-tag.yml@main + with: + product: opensearch-dashboards + tests: name: Run unit tests runs-on: ubuntu-latest + needs: Get-CI-Image-Tag + container: + # using the same image which is used by opensearch-build team to build the OpenSearch Distribution + # this image tag is subject to change as more dependencies and updates will arrive over time + image: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-version-linux }} + # need to switch to root so that github actions can install runner binary on container without permission issues. + options: --user root + steps: - name: Checkout OpenSearch Dashboards uses: actions/checkout@v2 @@ -19,31 +32,16 @@ jobs: repository: opensearch-project/OpenSearch-Dashboards ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} path: OpenSearch-Dashboards - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version-file: './OpenSearch-Dashboards/.nvmrc' - registry-url: 'https://registry.npmjs.org' - - name: Install Yarn - # Need to use bash to avoid having a windows/linux specific step - shell: bash - run: | - YARN_VERSION=$(node -p "require('./OpenSearch-Dashboards/package.json').engines.yarn") - echo "Installing yarn@$YARN_VERSION" - npm i -g yarn@$YARN_VERSION - - run: node -v - - run: yarn -v - name: Checkout ML Commons OpenSearch Dashboards plugin uses: actions/checkout@v2 with: path: OpenSearch-Dashboards/plugins/ml-commons-dashboards - - name: Bootstrap plugin/opensearch-dashboards - run: | - cd OpenSearch-Dashboards/plugins/ml-commons-dashboards - yarn osd bootstrap - - name: Run tests with coverage + - name: Bootstrap / build / unittest run: | - cd OpenSearch-Dashboards/plugins/ml-commons-dashboards - yarn run test:jest --silent --coverage + chown -R 1000:1000 `pwd` + cd ./OpenSearch-Dashboards/ + su `id -un 1000` -c "source $NVM_DIR/nvm.sh && nvm use && node -v && yarn -v && + cd ./plugins/ml-commons-dashboards && + whoami && yarn osd bootstrap && yarn run test:jest --silent --coverage" - name: Uploads coverage uses: codecov/codecov-action@v1 diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 56f40140..dee89959 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -1,7 +1,7 @@ { "id": "mlCommonsDashboards", - "version": "2.8.0.0", - "opensearchDashboardsVersion": "2.8.0", + "version": "2.12.0.0", + "opensearchDashboardsVersion": "2.12.0", "server": true, "ui": true, "requiredPlugins": [ diff --git a/package.json b/package.json index 6c8d0a70..1951d961 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "config": { "id": "mlCommonsDashboards" }, - "version": "2.8.0.0", + "version": "2.12.0.0", "scripts": { "build": "yarn plugin-helpers build", "plugin-helpers": "node ../../scripts/plugin_helpers", diff --git a/public/apis/__mocks__/connector.ts b/public/apis/__mocks__/connector.ts new file mode 100644 index 00000000..074e47a9 --- /dev/null +++ b/public/apis/__mocks__/connector.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export class Connector { + public async getAll() { + return { + data: [ + { + id: 'external-connector-1-id', + name: 'External Connector 1', + }, + ], + total_connectors: 1, + }; + } + + public async getAllInternal() { + return { + data: ['Internal Connector 1', 'Common Connector'], + }; + } +} diff --git a/public/apis/api_provider.ts b/public/apis/api_provider.ts index 2bbcaf8e..dae6c4b7 100644 --- a/public/apis/api_provider.ts +++ b/public/apis/api_provider.ts @@ -5,6 +5,7 @@ import { ModelVersion } from './model_version'; import { ModelAggregate } from './model_aggregate'; +import { Connector } from './connector'; import { Model } from './model'; import { ModelRepository } from './model_repository'; import { Profile } from './profile'; @@ -19,6 +20,7 @@ const apiInstanceStore: { task: Task | undefined; modelRepository: ModelRepository | undefined; model: Model | undefined; + connector: Connector | undefined; } = { modelVersion: undefined, modelAggregate: undefined, @@ -27,6 +29,7 @@ const apiInstanceStore: { task: undefined, modelRepository: undefined, model: undefined, + connector: undefined, }; export class APIProvider { @@ -37,6 +40,7 @@ export class APIProvider { public static getAPI(type: 'security'): Security; public static getAPI(type: 'modelRepository'): ModelRepository; public static getAPI(type: 'model'): Model; + public static getAPI(type: 'connector'): Connector; public static getAPI(type: keyof typeof apiInstanceStore) { if (apiInstanceStore[type]) { return apiInstanceStore[type]!; @@ -77,6 +81,11 @@ export class APIProvider { apiInstanceStore.model = newInstance; return newInstance; } + case 'connector': { + const newInstance = new Connector(); + apiInstanceStore.connector = newInstance; + return newInstance; + } } } public static clear() { diff --git a/public/apis/connector.ts b/public/apis/connector.ts new file mode 100644 index 00000000..dc742dd1 --- /dev/null +++ b/public/apis/connector.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CONNECTOR_API_ENDPOINT, + INTERNAL_CONNECTOR_API_ENDPOINT, +} from '../../server/routes/constants'; +import { InnerHttpProvider } from './inner_http_provider'; + +export interface GetAllConnectorResponse { + data: Array<{ + id: string; + name: string; + description?: string; + }>; + total_connectors: number; +} + +interface GetAllInternalConnectorResponse { + data: string[]; +} + +export class Connector { + public getAll() { + return InnerHttpProvider.getHttp().get(CONNECTOR_API_ENDPOINT); + } + + public getAllInternal() { + return InnerHttpProvider.getHttp().get( + INTERNAL_CONNECTOR_API_ENDPOINT + ); + } +} diff --git a/public/apis/model_version.ts b/public/apis/model_version.ts index bb9af571..a9c78c00 100644 --- a/public/apis/model_version.ts +++ b/public/apis/model_version.ts @@ -34,6 +34,11 @@ export interface ModelVersionSearchItem { last_registered_time?: number; last_deployed_time?: number; last_undeployed_time?: number; + connector_id?: string; + connector?: { + name: string; + description?: string; + }; } export interface ModelVersionDetail extends ModelVersionSearchItem { @@ -106,9 +111,11 @@ export class ModelVersion { nameOrId?: string; versionOrKeyword?: string; modelIds?: string[]; + extraQuery?: Record; }) { + const { extraQuery, ...restQuery } = query; return InnerHttpProvider.getHttp().get(MODEL_VERSION_API_ENDPOINT, { - query, + query: extraQuery ? { ...restQuery, extra_query: JSON.stringify(extraQuery) } : restQuery, }); } diff --git a/public/components/common/options_filter/options_filter.tsx b/public/components/common/options_filter/options_filter.tsx index 52a4dcda..155fa0d0 100644 --- a/public/components/common/options_filter/options_filter.tsx +++ b/public/components/common/options_filter/options_filter.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiPopover, EuiPopoverTitle, @@ -16,6 +16,7 @@ import { import { OptionsFilterItem } from './options_filter_item'; export interface OptionsFilterProps { + id?: string; name: string; searchPlaceholder: string; searchWidth?: number; @@ -33,11 +34,8 @@ export const OptionsFilter = ({ searchPlaceholder, searchWidth, onChange, + ...restProps }: OptionsFilterProps) => { - const valueRef = useRef(value); - valueRef.current = value; - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [searchText, setSearchText] = useState(); @@ -61,17 +59,19 @@ export const OptionsFilter = ({ setIsPopoverOpen(false); }, []); - const handleFilterItemClick = useCallback((clickItemValue: T) => { - onChangeRef.current( - valueRef.current.includes(clickItemValue) - ? valueRef.current.filter((item) => item !== clickItemValue) - : valueRef.current.concat(clickItemValue) - ); - }, []); + const handleFilterItemClick = useCallback( + (clickItemValue: T) => { + onChange( + value.includes(clickItemValue) + ? value.filter((item) => item !== clickItemValue) + : value.concat(clickItemValue) + ); + }, + [value, onChange] + ); return ( ({ isOpen={isPopoverOpen} closePopover={closePopover} panelPaddingSize="none" + {...restProps} > ', () => { - it('should navigate to # when clicking link of "Machine Learning Documentation"', () => { - render(); - const link = screen.getByText('Machine Learning Documentation'); - expect(link.getAttribute('href')).toBe( - 'https://opensearch.org/docs/latest/ml-commons-plugin/ml-dashbaord/' - ); - }); - - it('should navigate to # when clicking forum.opensearch.org', () => { - render(); - const link = screen.getByText('forum.opensearch.org'); - expect(link.getAttribute('href')).toBe( - 'https://forum.opensearch.org/t/feedback-ml-commons-ml-model-health-dashboard-for-admins-experimental-release/12494' - ); - }); -}); diff --git a/public/components/experiment_warning/index.tsx b/public/components/experiment_warning/index.tsx deleted file mode 100644 index 668fcc0a..00000000 --- a/public/components/experiment_warning/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ -import React from 'react'; - -import { EuiCallOut, EuiLink } from '@elastic/eui'; - -export const ExperimentalWarning = () => { - return ( - - The feature is experimental and should not be used in a production environment. For more - information, see{' '} - - Machine Learning Documentation - - . To leave feedback, visit{' '} - - forum.opensearch.org - - . - - ); -}; diff --git a/public/components/model/model_versions_panel/__tests__/model_version_table.test.tsx b/public/components/model/model_versions_panel/__tests__/model_version_table.test.tsx index 759e0338..29458232 100644 --- a/public/components/model/model_versions_panel/__tests__/model_version_table.test.tsx +++ b/public/components/model/model_versions_panel/__tests__/model_version_table.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { within } from '@testing-library/dom'; +import { within, fireEvent } from '@testing-library/dom'; import { render, screen, waitFor } from '../../../../../test/test_utils'; import { ModelVersionTable } from '../model_version_table'; @@ -45,7 +45,6 @@ describe('', () => { it( 'should render sorted column and call onSort after sort change', async () => { - const user = userEvent.setup(); const onSortMock = jest.fn(); render( ', () => { timeout: 2000, } ); - await user.click(screen.getByText('Version')); + await waitFor(async () => { + fireEvent.click(screen.getByText('Version')); expect(screen.getByText('Sort A-Z').closest('li')).toHaveClass( 'euiDataGridHeader__action--selected' ); }); expect(onSortMock).not.toHaveBeenCalled(); - await user.click(screen.getByText('Sort Z-A')); - expect(onSortMock).toHaveBeenCalledWith([{ direction: 'desc', id: 'version' }]); + await waitFor(() => { + fireEvent.click(screen.getByText('Sort Z-A')); + expect(onSortMock).toHaveBeenCalledWith([{ direction: 'desc', id: 'version' }]); + }); }, 40 * 1000 ); @@ -83,14 +85,18 @@ describe('', () => { it( 'should NOT render sort button for state and status column', async () => { - const user = userEvent.setup(); render(); - await user.click(screen.getByText('State')); - expect(screen.queryByTitle('Sort A-Z')).toBeNull(); + expect(screen.getByTestId('dataGridHeaderCell-state')).toBeInTheDocument(); + await waitFor(() => { + fireEvent.click(screen.getByTestId('dataGridHeaderCell-state')); + expect(screen.queryByTitle('Sort A-Z')).toBeNull(); + }); - await user.click(screen.getByText('Status')); - expect(screen.queryByTitle('Sort A-Z')).toBeNull(); + await waitFor(() => { + fireEvent.click(screen.getByTestId('dataGridHeaderCell-status')); + expect(screen.queryByTitle('Sort A-Z')).toBeNull(); + }); }, 20 * 1000 ); diff --git a/public/components/monitoring/index.scss b/public/components/monitoring/index.scss index f9809d2c..f44332e6 100644 --- a/public/components/monitoring/index.scss +++ b/public/components/monitoring/index.scss @@ -15,7 +15,7 @@ .ml-modelModelIdText { &:hover{ cursor: pointer; - color: #006BB4; + color: $ouiLinkColor; text-decoration: underline; } } diff --git a/public/components/monitoring/index.tsx b/public/components/monitoring/index.tsx index 64f07290..e8516a41 100644 --- a/public/components/monitoring/index.tsx +++ b/public/components/monitoring/index.tsx @@ -11,17 +11,20 @@ import { EuiFlexGroup, EuiFlexItem, EuiText, + EuiFilterGroup, } from '@elastic/eui'; import React, { useState, useRef, useCallback } from 'react'; +import { ModelDeploymentProfile } from '../../apis/profile'; import { RefreshInterval } from '../common/refresh_interval'; import { DebouncedSearchBar } from '../common'; import { PreviewPanel } from '../preview_panel'; -import { ExperimentalWarning } from '../experiment_warning'; + import { ModelDeploymentItem, ModelDeploymentTable } from './model_deployment_table'; import { useMonitoring } from './use_monitoring'; import { ModelStatusFilter } from './model_status_filter'; -import { ModelDeploymentProfile } from '../../apis/profile'; +import { ModelSourceFilter } from './model_source_filter'; +import { ModelConnectorFilter } from './model_connector_filter'; export const Monitoring = () => { const { @@ -34,6 +37,9 @@ export const Monitoring = () => { searchByNameOrId, reload, searchByStatus, + searchBySource, + searchByConnector, + allExternalConnectors, } = useMonitoring(); const [previewModel, setPreviewModel] = useState(null); const searchInputRef = useRef(); @@ -69,22 +75,17 @@ export const Monitoring = () => { return (
- - -
, - ]} + rightSideItems={[]} />

- Deployed models{' '} + Models{' '} {pageStatus !== 'empty' && ( ({pagination?.totalRecords ?? 0}) @@ -101,12 +102,20 @@ export const Monitoring = () => { - + + + + + diff --git a/public/components/monitoring/model_connector_filter.tsx b/public/components/monitoring/model_connector_filter.tsx new file mode 100644 index 00000000..42d5434e --- /dev/null +++ b/public/components/monitoring/model_connector_filter.tsx @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo } from 'react'; +import { OptionsFilter, OptionsFilterProps } from '../common/options_filter'; +import { useFetcher } from '../../hooks'; +import { APIProvider } from '../../apis/api_provider'; + +interface ModelConnectorFilterProps + extends Omit< + OptionsFilterProps, + 'name' | 'options' | 'searchPlaceholder' | 'loading' | 'value' | 'onChange' + > { + allExternalConnectors?: Array<{ id: string; name: string }>; + value: string[]; + onChange: (value: string[]) => void; +} + +export const ModelConnectorFilter = ({ + allExternalConnectors, + ...restProps +}: ModelConnectorFilterProps) => { + const { data: internalConnectorsResult } = useFetcher( + APIProvider.getAPI('connector').getAllInternal + ); + const options = useMemo( + () => + Array.from( + new Set( + (allExternalConnectors ?? []) + ?.map(({ name }) => name) + .concat(internalConnectorsResult?.data ?? []) + ) + ), + [internalConnectorsResult?.data, allExternalConnectors] + ); + + return ( + + ); +}; diff --git a/public/components/monitoring/model_deployment_table.tsx b/public/components/monitoring/model_deployment_table.tsx index 7cdfe023..01eb5310 100644 --- a/public/components/monitoring/model_deployment_table.tsx +++ b/public/components/monitoring/model_deployment_table.tsx @@ -12,12 +12,10 @@ import { EuiBasicTable, EuiButton, EuiButtonIcon, - EuiCopy, EuiEmptyPrompt, EuiHealth, EuiSpacer, EuiLink, - EuiText, EuiToolTip, } from '@elastic/eui'; @@ -41,6 +39,11 @@ export interface ModelDeploymentItem { planningNodesCount: number | undefined; notRespondingNodesCount: number | undefined; planningWorkerNodes: string[]; + connector?: { + id?: string; + name?: string; + description?: string; + }; } export interface ModelDeploymentTableProps { @@ -73,14 +76,34 @@ export const ModelDeploymentTable = ({ { field: 'name', name: 'Name', - width: '27.5%', + width: '23.84%', sortable: true, truncateText: true, }, + { + field: 'id', + name: 'Source', + width: '23.84%', + sortable: false, + truncateText: true, + render: (_id: string, modelDeploymentItem: ModelDeploymentItem) => { + return modelDeploymentItem.connector ? 'External' : 'Local'; + }, + }, + { + field: 'id', + name: 'Connector name', + width: '22.61%', + truncateText: true, + textOnly: true, + render: (_id: string, modelDeploymentItem: ModelDeploymentItem) => { + return modelDeploymentItem.connector?.name || '\u2014'; + }, + }, { field: 'model_state', name: 'Status', - width: '37.5%', + width: '23.84%', sortable: true, truncateText: true, render: ( @@ -92,63 +115,34 @@ export const ModelDeploymentTable = ({ respondingNodesCount === undefined || notRespondingNodesCount === undefined ) { - return '-'; + return '\u2014'; } if (respondingNodesCount === 0) { return ( -
- Not responding on {planningNodesCount} of{' '} - {planningNodesCount} nodes -
+
Not responding
); } if (notRespondingNodesCount === 0) { return ( -
- Responding on {planningNodesCount} of{' '} - {planningNodesCount} nodes -
+
Responding
); } return ( -
- Partially responding on{' '} - {respondingNodesCount} of {planningNodesCount} nodes -
+
Partially responding
); }, }, - { - field: 'id', - name: 'Model ID', - width: '25%', - sortable: true, - render: (id: string) => ( - - {(copy) => ( - - {id} - - )} - - ), - }, { field: 'id', name: 'Action', align: 'right' as const, - width: '10%', + width: '5.87%', render: (id: string, modelDeploymentItem: ModelDeploymentItem) => { return ( @@ -207,8 +201,9 @@ export const ModelDeploymentTable = ({ Deployed models will appear here. For more information, see{' '} Machine Learning Documentation diff --git a/public/components/monitoring/model_source_filter.tsx b/public/components/monitoring/model_source_filter.tsx new file mode 100644 index 00000000..e1063586 --- /dev/null +++ b/public/components/monitoring/model_source_filter.tsx @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { OptionsFilter, OptionsFilterProps } from '../common/options_filter'; + +type SourceOptionValue = 'local' | 'external'; + +const SOURCE_OPTIONS = [ + { + name: 'Local', + value: 'local' as const, + }, + { + name: 'External', + value: 'external' as const, + }, +]; + +export const ModelSourceFilter = ( + props: Omit, 'name' | 'options' | 'searchPlaceholder'> +) => { + return ( + + id="modelSourceFilter" + name="Source" + searchPlaceholder="Search" + options={SOURCE_OPTIONS} + {...props} + /> + ); +}; diff --git a/public/components/monitoring/tests/index.test.tsx b/public/components/monitoring/tests/index.test.tsx index 682c2889..9fdb0a5c 100644 --- a/public/components/monitoring/tests/index.test.tsx +++ b/public/components/monitoring/tests/index.test.tsx @@ -11,6 +11,12 @@ import { Monitoring } from '../index'; import * as useMonitoringExports from '../use_monitoring'; import { APIProvider } from '../../../apis/api_provider'; +jest.mock('../../../../../../src/plugins/opensearch_dashboards_react/public', () => { + return { + useUiSetting: jest.fn().mockReturnValue(false), + }; +}); + const setup = ( monitoringReturnValue?: Partial> ) => { @@ -20,6 +26,8 @@ const setup = ( currentPage: 1, pageSize: 15, sort: { field: 'name', direction: 'asc' }, + connector: [], + source: [], }, pageStatus: 'normal', pagination: { @@ -36,6 +44,9 @@ const setup = ( notRespondingNodesCount: 2, planningNodesCount: 3, planningWorkerNodes: ['node1', 'node2', 'node3'], + connector: { + name: 'Internal Connector 1', + }, }, { id: 'model-2-id', @@ -44,6 +55,9 @@ const setup = ( notRespondingNodesCount: 0, planningNodesCount: 3, planningWorkerNodes: ['node1', 'node2', 'node3'], + connector: { + name: 'External Connector 1', + }, }, { id: 'model-3-id', @@ -54,9 +68,17 @@ const setup = ( planningWorkerNodes: ['node1', 'node2', 'node3'], }, ], + allExternalConnectors: [ + { + id: 'external-connector-id-1', + name: 'External Connector 1', + }, + ], reload: jest.fn(), searchByNameOrId: jest.fn(), searchByStatus: jest.fn(), + searchByConnector: jest.fn(), + searchBySource: jest.fn(), updateDeployedModel: jest.fn(), resetSearch: jest.fn(), handleTableChange: jest.fn(), @@ -152,9 +174,10 @@ describe('', () => { }); it('should render normal monitoring', () => { setup(); - expect(screen.getByText('model-1-id')).toBeInTheDocument(); + expect(screen.getByText('Internal Connector 1')).toBeInTheDocument(); expect(screen.getByText('model 2 name')).toBeInTheDocument(); - expect(screen.getByText('model-3-id')).toBeInTheDocument(); + expect(screen.getByText('Local')).toBeInTheDocument(); + expect(screen.getAllByText('External')).toHaveLength(2); }); }); @@ -187,15 +210,15 @@ describe('', () => { pageStatus: 'reset-filter', deployedModels: [], }); - await user.type(screen.getByLabelText(/Search by name or ID/i), 'test model name'); + await user.type(screen.getByLabelText(/Search by model name or ID/i), 'test model name'); expect(screen.getByLabelText('no models results')).toBeInTheDocument(); - expect(screen.getByLabelText(/Search by name or ID/i)).toHaveValue('test model name'); + expect(screen.getByLabelText(/Search by model name or ID/i)).toHaveValue('test model name'); await user.click(screen.getByText('Reset search')); expect(resetSearch).toHaveBeenCalled(); // Search input should get reset - expect(screen.getByLabelText(/Search by name or ID/i)).toHaveValue(''); + expect(screen.getByLabelText(/Search by model name or ID/i)).toHaveValue(''); }); it('should search with user input', async () => { @@ -208,7 +231,7 @@ describe('', () => { deployedModels: [], searchByNameOrId: mockSearchByNameOrId, }); - await user.type(screen.getByLabelText(/Search by name or ID/i), 'test model name'); + await user.type(screen.getByLabelText(/Search by model name or ID/i), 'test model name'); await waitFor(() => expect(searchByNameOrId).toHaveBeenCalledWith('test model name')); }); @@ -270,6 +293,42 @@ describe('', () => { clearOffsetMethodsMock(); }); + it('should call searchBySource after source filter option clicked', async () => { + const clearOffsetMethodsMock = mockOffsetMethods(); + + const { + finalMonitoringReturnValue: { searchBySource }, + user, + } = setup({}); + + await user.click(screen.getByText('Source', { selector: "[data-text='Source']" })); + + expect(searchBySource).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole('dialog')).getByText('Local')); + expect(searchBySource).toHaveBeenLastCalledWith(['local']); + + clearOffsetMethodsMock(); + }); + + it('should call searchByConnector after connector filter option clicked', async () => { + const clearOffsetMethodsMock = mockOffsetMethods(); + + const { + finalMonitoringReturnValue: { searchByConnector }, + user, + } = setup({}); + + await user.click( + screen.getByText('Connector name', { selector: "[data-text='Connector name']" }) + ); + + expect(searchByConnector).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole('dialog')).getByText('External Connector 1')); + expect(searchByConnector).toHaveBeenLastCalledWith(['External Connector 1']); + + clearOffsetMethodsMock(); + }); + it('should show preview panel after view detail button clicked', async () => { const { user } = setup(); await user.click(screen.getAllByRole('button', { name: 'view detail' })[0]); diff --git a/public/components/monitoring/tests/model_connector_filter.test.tsx b/public/components/monitoring/tests/model_connector_filter.test.tsx new file mode 100644 index 00000000..270c504a --- /dev/null +++ b/public/components/monitoring/tests/model_connector_filter.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from '../../../../test/test_utils'; +import { ModelConnectorFilter } from '../model_connector_filter'; + +jest.mock('../../../apis/connector'); + +async function setup(value: string[]) { + const onChangeMock = jest.fn(); + const user = userEvent.setup({}); + render( + + ); + await user.click(screen.getByText('Connector name')); + return { user, onChangeMock }; +} + +describe('', () => { + const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetHeight' + ); + const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + }); + + afterEach(() => { + Object.defineProperty( + HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeight as PropertyDescriptor + ); + Object.defineProperty( + HTMLElement.prototype, + 'offsetWidth', + originalOffsetWidth as PropertyDescriptor + ); + }); + + it('should render Connector filter and 1 selected filter number', async () => { + await setup(['External Connector 1']); + expect(screen.getByText('Connector name')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByLabelText('1 active filters')).toBeInTheDocument(); + }); + + it('should render all connectors in the option list', async () => { + await setup(['External Connector 1']); + await waitFor(() => { + expect( + within(screen.getByRole('dialog')).getByText('Internal Connector 1') + ).toBeInTheDocument(); + expect( + within(screen.getByRole('dialog')).getByText('External Connector 1') + ).toBeInTheDocument(); + expect(within(screen.getByRole('dialog')).getByText('Common Connector')).toBeInTheDocument(); + }); + }); + + it('should call onChange with consistent params after option click', async () => { + const { user, onChangeMock } = await setup(['External Connector 1']); + + await user.click(screen.getByText('Common Connector')); + + expect(onChangeMock).toHaveBeenLastCalledWith(['External Connector 1', 'Common Connector']); + }); +}); diff --git a/public/components/monitoring/tests/model_deployment_table.test.tsx b/public/components/monitoring/tests/model_deployment_table.test.tsx index b95aad76..020a60d2 100644 --- a/public/components/monitoring/tests/model_deployment_table.test.tsx +++ b/public/components/monitoring/tests/model_deployment_table.test.tsx @@ -9,6 +9,8 @@ import React from 'react'; import { render, screen, within } from '../../../../test/test_utils'; import { ModelDeploymentTableProps, ModelDeploymentTable } from '../model_deployment_table'; +jest.mock('../../../apis/connector'); + const setup = (props?: Partial) => { const finalProps = { items: [ @@ -19,6 +21,7 @@ const setup = (props?: Partial) => { notRespondingNodesCount: 2, planningNodesCount: 3, planningWorkerNodes: [], + source: 'Local', }, { id: 'model-2-id', @@ -27,6 +30,7 @@ const setup = (props?: Partial) => { notRespondingNodesCount: 0, planningNodesCount: 3, planningWorkerNodes: [], + source: 'Local', }, { id: 'model-3-id', @@ -35,6 +39,10 @@ const setup = (props?: Partial) => { notRespondingNodesCount: 3, planningNodesCount: 3, planningWorkerNodes: [], + source: 'External', + connector: { + name: 'Sagemaker', + }, }, ], pagination: { currentPage: 1, pageSize: 10, totalRecords: 100 }, @@ -58,7 +66,7 @@ describe('', () => { }); expect(screen.getByRole('link')).toBeInTheDocument(); expect(screen.getByRole('link').getAttribute('href')).toEqual( - 'https://opensearch.org/docs/latest/ml-commons-plugin/ml-dashbaord/' + 'https://opensearch.org/docs/latest/ml-commons-plugin/ml-dashboard/' ); expect(screen.queryByRole('columnheader')).not.toBeInTheDocument(); }); @@ -104,49 +112,53 @@ describe('', () => { expect(within(cells[2] as HTMLElement).getByText('model 3 name')).toBeInTheDocument(); }); - it('should render status at second column', () => { + it('should render source at second column', () => { const columnIndex = 1; setup(); const header = screen.getAllByRole('columnheader')[columnIndex]; const columnContent = header .closest('table') ?.querySelectorAll(`tbody tr td:nth-child(${columnIndex + 1})`); - expect(within(header).getByText('Status')).toBeInTheDocument(); + expect(within(header).getByText('Source')).toBeInTheDocument(); expect(columnContent?.length).toBe(3); const cells = columnContent!; - expect(within(cells[0] as HTMLElement).getByText('Partially responding')).toBeInTheDocument(); - expect(within(cells[0] as HTMLElement).getByText('on 1 of 3 nodes')).toBeInTheDocument(); - expect(within(cells[1] as HTMLElement).getByText('Responding')).toBeInTheDocument(); - expect(within(cells[1] as HTMLElement).getByText('on 3 of 3 nodes')).toBeInTheDocument(); - expect(within(cells[2] as HTMLElement).getByText('Not responding')).toBeInTheDocument(); - expect(within(cells[2] as HTMLElement).getByText('on 3 of 3 nodes')).toBeInTheDocument(); + expect(within(cells[0] as HTMLElement).getByText('Local')).toBeInTheDocument(); + expect(within(cells[1] as HTMLElement).getByText('Local')).toBeInTheDocument(); + expect(within(cells[2] as HTMLElement).getByText('External')).toBeInTheDocument(); }); - it('should render Model ID at third column and copy to clipboard after text clicked', async () => { - const execCommandOrigin = document.execCommand; - document.execCommand = jest.fn(() => true); - + it('should render connector name at second column', () => { const columnIndex = 2; setup(); const header = screen.getAllByRole('columnheader')[columnIndex]; const columnContent = header .closest('table') ?.querySelectorAll(`tbody tr td:nth-child(${columnIndex + 1})`); - expect(within(header).getByText('Model ID')).toBeInTheDocument(); + expect(within(header).getByText('Connector name')).toBeInTheDocument(); expect(columnContent?.length).toBe(3); const cells = columnContent!; - expect(within(cells[0] as HTMLElement).getByText('model-1-id')).toBeInTheDocument(); - expect(within(cells[1] as HTMLElement).getByText('model-2-id')).toBeInTheDocument(); - expect(within(cells[2] as HTMLElement).getByText('model-3-id')).toBeInTheDocument(); - - await userEvent.click(within(cells[0] as HTMLElement).getByText('model-1-id')); - expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(within(cells[0] as HTMLElement).getByText('\u2014')).toBeInTheDocument(); + expect(within(cells[1] as HTMLElement).getByText('\u2014')).toBeInTheDocument(); + expect(within(cells[2] as HTMLElement).getByText('Sagemaker')).toBeInTheDocument(); + }); - document.execCommand = execCommandOrigin; + it('should render status at fourth column', () => { + const columnIndex = 3; + setup(); + const header = screen.getAllByRole('columnheader')[columnIndex]; + const columnContent = header + .closest('table') + ?.querySelectorAll(`tbody tr td:nth-child(${columnIndex + 1})`); + expect(within(header).getByText('Status')).toBeInTheDocument(); + expect(columnContent?.length).toBe(3); + const cells = columnContent!; + expect(within(cells[0] as HTMLElement).getByText('Partially responding')).toBeInTheDocument(); + expect(within(cells[1] as HTMLElement).getByText('Responding')).toBeInTheDocument(); + expect(within(cells[2] as HTMLElement).getByText('Not responding')).toBeInTheDocument(); }); it('should render Action column and call onViewDetail with the model item of the current table row', async () => { - const columnIndex = 3; + const columnIndex = 4; const onViewDetailMock = jest.fn(); const { finalProps } = setup({ onViewDetail: onViewDetailMock, @@ -207,6 +219,7 @@ describe('', () => { }); it('should call onChange with consistent status sort parameters', async () => { + const statusColumnIndex = 3; const { finalProps, result: { rerender }, @@ -217,7 +230,9 @@ describe('', () => { }, }); - await userEvent.click(within(screen.getAllByRole('columnheader')[1]).getByText('Status')); + await userEvent.click( + within(screen.getAllByRole('columnheader')[statusColumnIndex]).getByText('Status') + ); expect(finalProps.onChange).toHaveBeenCalledWith( expect.objectContaining({ sort: { @@ -236,52 +251,13 @@ describe('', () => { }} /> ); - await userEvent.click(within(screen.getAllByRole('columnheader')[1]).getByText('Status')); - expect(finalProps.onChange).toHaveBeenCalledWith( - expect.objectContaining({ - sort: { - field: 'model_state', - direction: 'asc', - }, - }) - ); - }); - - it('should call onChange with consistent model id sort parameters', async () => { - const { - finalProps, - result: { rerender }, - } = setup({ - sort: { - field: 'id', - direction: 'asc', - }, - }); - - await userEvent.click(within(screen.getAllByRole('columnheader')[2]).getByText('Model ID')); - expect(finalProps.onChange).toHaveBeenCalledWith( - expect.objectContaining({ - sort: { - field: 'id', - direction: 'desc', - }, - }) - ); - - rerender( - + await userEvent.click( + within(screen.getAllByRole('columnheader')[statusColumnIndex]).getByText('Status') ); - await userEvent.click(within(screen.getAllByRole('columnheader')[2]).getByText('Model ID')); expect(finalProps.onChange).toHaveBeenCalledWith( expect.objectContaining({ sort: { - field: 'id', + field: 'model_state', direction: 'asc', }, }) diff --git a/public/components/monitoring/tests/model_source_filter.test.tsx b/public/components/monitoring/tests/model_source_filter.test.tsx new file mode 100644 index 00000000..19b8c593 --- /dev/null +++ b/public/components/monitoring/tests/model_source_filter.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../../test/test_utils'; +import { ModelSourceFilter } from '../model_source_filter'; + +async function setup(value: Array<'local' | 'external'>) { + const onChangeMock = jest.fn(); + const user = userEvent.setup({}); + render(); + await user.click(screen.getByText('Source')); + return { user, onChangeMock }; +} + +describe('', () => { + const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetHeight' + ); + const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + }); + + afterEach(() => { + Object.defineProperty( + HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeight as PropertyDescriptor + ); + Object.defineProperty( + HTMLElement.prototype, + 'offsetWidth', + originalOffsetWidth as PropertyDescriptor + ); + }); + + it('should render Source filter and 1 selected filter number', async () => { + await setup(['local']); + expect(screen.getByText('Source')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByLabelText('1 active filters')).toBeInTheDocument(); + }); + + it('should call onChange with consistent params after option click', async () => { + const { user, onChangeMock } = await setup(['local']); + + await user.click(screen.getByText('External')); + + expect(onChangeMock).toHaveBeenLastCalledWith(['local', 'external']); + }); +}); diff --git a/public/components/monitoring/tests/use_monitoring.test.ts b/public/components/monitoring/tests/use_monitoring.test.ts index 6d75f11a..5e713864 100644 --- a/public/components/monitoring/tests/use_monitoring.test.ts +++ b/public/components/monitoring/tests/use_monitoring.test.ts @@ -6,8 +6,11 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { ModelVersion, ModelVersionSearchResponse } from '../../../apis/model_version'; +import { Connector } from '../../../apis/connector'; import { useMonitoring } from '../use_monitoring'; +jest.mock('../../../apis/connector'); + const mockEmptyRecords = () => jest.spyOn(ModelVersion.prototype, 'search').mockResolvedValueOnce({ data: [], @@ -16,7 +19,21 @@ const mockEmptyRecords = () => describe('useMonitoring', () => { beforeEach(() => { - jest.spyOn(ModelVersion.prototype, 'search'); + jest.spyOn(ModelVersion.prototype, 'search').mockResolvedValue({ + data: [ + { + id: 'model-1-id', + name: 'model-1-name', + current_worker_node_count: 1, + planning_worker_node_count: 3, + algorithm: '', + model_state: 'DEPLOYED' as cosnt, + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + }, + ], + total_model_versions: 1, + }); }); afterEach(() => { @@ -28,9 +45,7 @@ describe('useMonitoring', () => { await waitFor(() => result.current.pageStatus === 'normal'); - act(() => { - result.current.searchByNameOrId('foo'); - }); + result.current.searchByNameOrId('foo'); await waitFor(() => expect(ModelVersion.prototype.search).toHaveBeenCalledWith( expect.objectContaining({ @@ -40,9 +55,7 @@ describe('useMonitoring', () => { ) ); - act(() => { - result.current.searchByStatus(['responding']); - }); + result.current.searchByStatus(['responding']); await waitFor(() => expect(ModelVersion.prototype.search).toHaveBeenCalledWith( expect.objectContaining({ @@ -52,13 +65,10 @@ describe('useMonitoring', () => { ) ); - act(() => { - result.current.resetSearch(); - }); + result.current.resetSearch(); await waitFor(() => result.current.pageStatus === 'normal'); - act(() => { - result.current.searchByStatus(['partial-responding']); - }); + + result.current.searchByStatus(['partial-responding']); await waitFor(() => expect(ModelVersion.prototype.search).toHaveBeenCalledWith( expect.objectContaining({ @@ -81,11 +91,9 @@ describe('useMonitoring', () => { ) ); - act(() => { - result.current.handleTableChange({ - sort: { field: 'name', direction: 'desc' }, - pagination: { currentPage: 2, pageSize: 10 }, - }); + result.current.handleTableChange({ + sort: { field: 'name', direction: 'desc' }, + pagination: { currentPage: 2, pageSize: 10 }, }); await waitFor(() => expect(ModelVersion.prototype.search).toHaveBeenCalledWith( @@ -103,11 +111,302 @@ describe('useMonitoring', () => { await waitFor(() => expect(ModelVersion.prototype.search).toHaveBeenCalledTimes(1)); - act(() => { - result.current.reload(); - }); + result.current.reload(); await waitFor(() => expect(ModelVersion.prototype.search).toHaveBeenCalledTimes(2)); }); + + it('should return consistent deployedModels', async () => { + jest.spyOn(ModelVersion.prototype, 'search').mockRestore(); + const searchMock = jest.spyOn(ModelVersion.prototype, 'search').mockResolvedValue({ + data: [ + { + id: 'model-1-id', + name: 'model-1-name', + current_worker_node_count: 1, + planning_worker_node_count: 3, + algorithm: 'TEXT_EMBEDDING', + model_state: 'DEPLOYED', + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + }, + { + id: 'model-2-id', + name: 'model-2-name', + current_worker_node_count: 1, + planning_worker_node_count: 3, + algorithm: 'REMOTE', + model_state: 'DEPLOYED', + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + connector_id: 'external-connector-1-id', + }, + { + id: 'model-3-id', + name: 'model-3-name', + current_worker_node_count: 1, + planning_worker_node_count: 3, + algorithm: 'REMOTE', + model_state: 'DEPLOYED', + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + connector: { + name: 'Internal Connector 1', + }, + }, + ], + total_model_versions: 3, + }); + const { result, waitFor } = renderHook(() => useMonitoring()); + + await waitFor(() => { + expect(result.current.deployedModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model-1-id', + name: 'model-1-name', + respondingNodesCount: 1, + notRespondingNodesCount: 2, + planningNodesCount: 3, + planningWorkerNodes: ['node1', 'node2', 'node3'], + }), + expect.objectContaining({ + connector: expect.objectContaining({ + name: 'External Connector 1', + }), + }), + expect.objectContaining({ + connector: expect.objectContaining({ + name: 'Internal Connector 1', + }), + }), + ]) + ); + }); + + searchMock.mockRestore(); + }); + + it('should return empty connector if connector id not exists in all connectors', async () => { + jest.spyOn(ModelVersion.prototype, 'search').mockRestore(); + const searchMock = jest.spyOn(ModelVersion.prototype, 'search').mockResolvedValue({ + data: [ + { + id: 'model-1-id', + name: 'model-1-name', + current_worker_node_count: 1, + planning_worker_node_count: 3, + algorithm: 'REMOTE', + model_state: 'DEPLOYED', + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + connector_id: 'not-exists-external-connector-id', + }, + ], + total_model_versions: 1, + }); + const { result, waitFor } = renderHook(() => useMonitoring()); + + await waitFor(() => { + expect(result.current.deployedModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + connector: {}, + }), + ]) + ); + }); + + searchMock.mockRestore(); + }); + + it('should return empty connector if failed to load all external connectors', async () => { + jest.spyOn(ModelVersion.prototype, 'search').mockRestore(); + const getAllExternalConnectorsMock = jest + .spyOn(Connector.prototype, 'getAll') + .mockImplementation(async () => { + throw new Error(); + }); + const searchMock = jest.spyOn(ModelVersion.prototype, 'search').mockResolvedValue({ + data: [ + { + id: 'model-1-id', + name: 'model-1-name', + current_worker_node_count: 1, + planning_worker_node_count: 3, + algorithm: 'REMOTE', + model_state: 'DEPLOYED', + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + connector_id: 'not-exists-external-connector-id', + }, + ], + total_model_versions: 1, + }); + const { result, waitFor } = renderHook(() => useMonitoring()); + + await waitFor(() => { + expect(result.current.deployedModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + connector: {}, + }), + ]) + ); + }); + + searchMock.mockRestore(); + getAllExternalConnectorsMock.mockRestore(); + }); + + it('should call searchByNameOrId with from 0 after page changed', async () => { + const { result, waitFor } = renderHook(() => useMonitoring()); + + result.current.handleTableChange({ + pagination: { + currentPage: 2, + pageSize: 15, + }, + }); + + await waitFor(() => { + expect(result.current.pagination?.currentPage).toBe(2); + }); + + result.current.searchByNameOrId('foo'); + + await waitFor(() => { + expect(ModelVersion.prototype.search).toHaveBeenCalledTimes(3); + expect(ModelVersion.prototype.search).toHaveBeenLastCalledWith( + expect.objectContaining({ + from: 0, + }) + ); + }); + }); + + it('should call searchByStatus with from 0 after page changed', async () => { + const { result, waitFor } = renderHook(() => useMonitoring()); + + result.current.handleTableChange({ + pagination: { + currentPage: 2, + pageSize: 15, + }, + }); + + await waitFor(() => { + expect(result.current.pagination?.currentPage).toBe(2); + }); + + result.current.searchByStatus(['responding']); + + await waitFor(() => { + expect(ModelVersion.prototype.search).toHaveBeenCalledTimes(3); + expect(ModelVersion.prototype.search).toHaveBeenLastCalledWith( + expect.objectContaining({ + from: 0, + }) + ); + }); + }); + + it('should call search API with consistent extraQuery after source filter applied', async () => { + const { result, waitFor } = renderHook(() => useMonitoring()); + + await waitFor(() => result.current.pageStatus === 'normal'); + + result.current.searchBySource(['local']); + await waitFor(() => + expect(ModelVersion.prototype.search).toHaveBeenLastCalledWith( + expect.objectContaining({ + extraQuery: { + bool: { + must_not: [ + { + term: { + algorithm: { + value: 'REMOTE', + }, + }, + }, + ], + }, + }, + }) + ) + ); + + result.current.searchBySource(['external']); + await waitFor(() => + expect(ModelVersion.prototype.search).toHaveBeenLastCalledWith( + expect.objectContaining({ + extraQuery: { + bool: { + must: [ + { + term: { + algorithm: { + value: 'REMOTE', + }, + }, + }, + ], + }, + }, + }) + ) + ); + + result.current.searchBySource(['external', 'local']); + await waitFor(() => + expect(ModelVersion.prototype.search).toHaveBeenLastCalledWith( + expect.objectContaining({ + extraQuery: undefined, + }) + ) + ); + }); + + it('should call search API with consistent extraQuery after connector filter applied', async () => { + const { result, waitFor } = renderHook(() => useMonitoring()); + + await waitFor(() => result.current.pageStatus === 'normal'); + + result.current.searchByConnector(['External Connector 1']); + await waitFor(() => + expect(ModelVersion.prototype.search).toHaveBeenLastCalledWith( + expect.objectContaining({ + extraQuery: { + bool: { + must: [ + { + bool: { + should: [ + { + wildcard: { + 'connector.name.keyword': { + value: '*External Connector 1*', + case_insensitive: true, + }, + }, + }, + { + terms: { + 'connector_id.keyword': ['external-connector-1-id'], + }, + }, + ], + }, + }, + ], + }, + }, + }) + ) + ); + + await waitFor(() => result.current.pageStatus === 'normal'); + }); }); describe('useMonitoring.pageStatus', () => { @@ -153,9 +452,7 @@ describe('useMonitoring.pageStatus', () => { // Mock search function to return empty result mockEmptyRecords(); - act(() => { - result.current.searchByNameOrId('foo'); - }); + result.current.searchByNameOrId('foo'); await waitFor(() => expect(result.current.pageStatus).toBe('reset-filter')); }); @@ -167,31 +464,38 @@ describe('useMonitoring.pageStatus', () => { // assume result is empty mockEmptyRecords(); - act(() => { - result.current.searchByStatus(['responding']); - }); + result.current.searchByStatus(['responding']); await waitFor(() => expect(result.current.pageStatus).toBe('reset-filter')); }); - it("should return 'empty' if empty data return", async () => { + it("should return 'reset-filter' when filter by source but no result was found", async () => { + const { result, waitFor } = renderHook(() => useMonitoring()); + + // Page status is normal for the initial run(search returns mocked results) + await waitFor(() => expect(result.current.pageStatus).toBe('normal')); + + // assume result is empty mockEmptyRecords(); + result.current.searchBySource(['local']); + await waitFor(() => expect(result.current.pageStatus).toBe('reset-filter')); + }); + + it("should return 'reset-filter' when filter by connector but no result was found", async () => { const { result, waitFor } = renderHook(() => useMonitoring()); - await waitFor(() => expect(result.current.pageStatus).toBe('empty')); + // Page status is normal for the initial run(search returns mocked results) + await waitFor(() => expect(result.current.pageStatus).toBe('normal')); + + // assume result is empty + mockEmptyRecords(); + result.current.searchByConnector(['Sagemaker']); + await waitFor(() => expect(result.current.pageStatus).toBe('reset-filter')); }); - it('should return consistent deployedModels', async () => { + it("should return 'empty' if empty data return", async () => { + mockEmptyRecords(); const { result, waitFor } = renderHook(() => useMonitoring()); - await waitFor(() => - expect(result.current.deployedModels[0]).toEqual({ - id: '1', - name: 'model1', - respondingNodesCount: 1, - planningNodesCount: 3, - notRespondingNodesCount: 2, - planningWorkerNodes: ['node1', 'node2', 'node3'], - }) - ); + await waitFor(() => expect(result.current.pageStatus).toBe('empty')); }); }); diff --git a/public/components/monitoring/use_monitoring.ts b/public/components/monitoring/use_monitoring.ts index 850ab1df..4471d14b 100644 --- a/public/components/monitoring/use_monitoring.ts +++ b/public/components/monitoring/use_monitoring.ts @@ -6,6 +6,7 @@ import { useMemo, useCallback, useState } from 'react'; import { APIProvider } from '../../apis/api_provider'; +import { GetAllConnectorResponse } from '../../apis/connector'; import { useFetcher } from '../../hooks/use_fetcher'; import { MODEL_VERSION_STATE } from '../../../common'; @@ -14,18 +15,72 @@ import { ModelDeployStatus } from './types'; interface Params { nameOrId?: string; status?: ModelDeployStatus[]; + source: Array<'local' | 'external'>; + connector: string[]; currentPage: number; pageSize: number; sort: { field: 'name' | 'model_state' | 'id'; direction: 'asc' | 'desc' }; } +const generateExtraQuery = ({ + source, + connector, +}: Pick & { connector: Array<{ name: string; ids: string[] }> }) => { + if (connector.length === 0 && source.length === 0) { + return undefined; + } + const must: Array> = []; + const mustNot: Array> = []; + + if (source.length === 1) { + (source[0] === 'external' ? must : mustNot).push({ + term: { + algorithm: { value: 'REMOTE' }, + }, + }); + } + + if (connector.length > 0) { + const should: Array> = []; + connector.forEach(({ name, ids }) => { + should.push({ + wildcard: { + 'connector.name.keyword': { value: `*${name}*`, case_insensitive: true }, + }, + }); + if (ids.length > 0) { + should.push({ + terms: { + 'connector_id.keyword': ids, + }, + }); + } + }); + must.push({ bool: { should } }); + } + + if (must.length === 0 && mustNot.length === 0) { + return undefined; + } + + return { + bool: { + ...(must.length > 0 ? { must } : {}), + ...(mustNot.length > 0 ? { must_not: mustNot } : {}), + }, + }; +}; + const isValidNameOrIdFilter = (nameOrId: string | undefined): nameOrId is string => !!nameOrId; const isValidStatusFilter = ( status: ModelDeployStatus[] | undefined ): status is ModelDeployStatus[] => !!status && status.length > 0; const checkFilterExists = (params: Params) => - isValidNameOrIdFilter(params.nameOrId) || isValidStatusFilter(params.status); + isValidNameOrIdFilter(params.nameOrId) || + isValidStatusFilter(params.status) || + params.connector.length > 0 || + params.source.length > 0; const fetchDeployedModels = async (params: Params) => { const states = params.status?.map((status) => { @@ -38,6 +93,12 @@ const fetchDeployedModels = async (params: Params) => { return MODEL_VERSION_STATE.partiallyDeployed; } }); + let externalConnectorsData: GetAllConnectorResponse; + try { + externalConnectorsData = await APIProvider.getAPI('connector').getAll(); + } catch (_e) { + externalConnectorsData = { data: [], total_connectors: 0 }; + } const result = await APIProvider.getAPI('modelVersion').search({ from: (params.currentPage - 1) * params.pageSize, size: params.pageSize, @@ -51,7 +112,27 @@ const fetchDeployedModels = async (params: Params) => { ] : states, sort: [`${params.sort.field}-${params.sort.direction}`], + extraQuery: generateExtraQuery({ + ...params, + connector: + params.connector.length > 0 + ? params.connector.map((connectorItem) => ({ + name: connectorItem, + ids: externalConnectorsData.data + .filter((item) => item.name === connectorItem) + .map(({ id }) => id), + })) + : [], + }), }); + const externalConnectorMap = externalConnectorsData.data.reduce<{ + [key: string]: { + id: string; + name: string; + description?: string; + }; + }>((previousValue, currentValue) => ({ ...previousValue, [currentValue.id]: currentValue }), {}); + const totalPages = Math.ceil(result.total_model_versions / params.pageSize); return { pagination: { @@ -67,6 +148,8 @@ const fetchDeployedModels = async (params: Params) => { current_worker_node_count: workerCount, planning_worker_node_count: planningCount, planning_worker_nodes: planningWorkerNodes, + algorithm, + ...rest }) => { return { id, @@ -78,9 +161,13 @@ const fetchDeployedModels = async (params: Params) => { ? planningCount - workerCount : undefined, planningWorkerNodes, + connector: rest.connector_id + ? externalConnectorMap[rest.connector_id] || {} + : rest.connector, }; } ), + allExternalConnectors: externalConnectorsData.data, }; }; @@ -89,6 +176,8 @@ export const useMonitoring = () => { currentPage: 1, pageSize: 10, sort: { field: 'model_state', direction: 'asc' }, + source: [], + connector: [], }); const { data, loading, reload } = useFetcher(fetchDeployedModels, params); const filterExists = checkFilterExists(params); @@ -117,6 +206,8 @@ export const useMonitoring = () => { currentPage: previousValue.currentPage, pageSize: previousValue.pageSize, sort: previousValue.sort, + source: [], + connector: [], })); }, []); @@ -124,6 +215,7 @@ export const useMonitoring = () => { setParams((previousValue) => ({ ...previousValue, nameOrId, + currentPage: 1, })); }, []); @@ -131,6 +223,23 @@ export const useMonitoring = () => { setParams((previousValue) => ({ ...previousValue, status, + currentPage: 1, + })); + }, []); + + const searchBySource = useCallback((source: Params['source']) => { + setParams((previousValue) => ({ + ...previousValue, + source, + currentPage: 1, + })); + }, []); + + const searchByConnector = useCallback((connector: Params['connector']) => { + setParams((previousValue) => ({ + ...previousValue, + connector, + currentPage: 1, })); }, []); @@ -166,9 +275,12 @@ export const useMonitoring = () => { * Data of the current page */ deployedModels, + allExternalConnectors: data?.allExternalConnectors, reload, searchByStatus, searchByNameOrId, + searchBySource, + searchByConnector, resetSearch, handleTableChange, }; diff --git a/public/components/preview_panel/__tests__/connector_details.test.tsx b/public/components/preview_panel/__tests__/connector_details.test.tsx new file mode 100644 index 00000000..c695ab47 --- /dev/null +++ b/public/components/preview_panel/__tests__/connector_details.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../../test/test_utils'; +import { ConnectorDetails } from '../connector_details'; + +function setup({ name = 'name', id = 'id', description = 'description' }) { + const user = userEvent.setup({}); + render(); + return { user }; +} + +describe('', () => { + it('should render connector details', () => { + setup({}); + expect(screen.getByText('Connector name')).toBeInTheDocument(); + expect(screen.getByText('Connector ID')).toBeInTheDocument(); + expect(screen.getByText('Connector description')).toBeInTheDocument(); + }); + + it('should render em dash when id is empty', () => { + setup({ id: '' }); + expect(screen.getByText('\u2014')).toBeInTheDocument(); + expect(screen.queryByTestId('copyable-text-div')).not.toBeInTheDocument(); + }); + + it('should render id and copy id button when id is not empty', () => { + setup({ id: 'connector-id' }); + expect(screen.getByText('connector-id')).toBeInTheDocument(); + expect(screen.queryByTestId('copyable-text-div')).toBeInTheDocument(); + }); +}); diff --git a/public/components/preview_panel/__tests__/nodes_table.test.tsx b/public/components/preview_panel/__tests__/nodes_table.test.tsx index 2a9a2feb..dfba1868 100644 --- a/public/components/preview_panel/__tests__/nodes_table.test.tsx +++ b/public/components/preview_panel/__tests__/nodes_table.test.tsx @@ -19,9 +19,9 @@ const NODES = [ }, ]; -function setup({ nodes = NODES, loading = false }) { +function setup({ nodes = NODES, loading = false, nodesStatus = 'Responding on 1 of 2 nodes' }) { const user = userEvent.setup({}); - render(); + render(); return { user }; } @@ -31,6 +31,7 @@ describe('', () => { expect(screen.getAllByRole('columnheader').length).toBe(2); expect(screen.getByText('id1')).toBeInTheDocument(); expect(screen.getByText('id2')).toBeInTheDocument(); + expect(screen.getByText('Responding on 1 of 2 nodes')).toBeInTheDocument(); }); it('should render status at first column with asc by default', () => { diff --git a/public/components/preview_panel/__tests__/preview_panel.test.tsx b/public/components/preview_panel/__tests__/preview_panel.test.tsx index 95e75965..7da93f4b 100644 --- a/public/components/preview_panel/__tests__/preview_panel.test.tsx +++ b/public/components/preview_panel/__tests__/preview_panel.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { act, render, screen, waitFor } from '../../../../test/test_utils'; +import { render, screen, waitFor } from '../../../../test/test_utils'; import { PreviewPanel } from '../'; import { APIProvider } from '../../../apis/api_provider'; @@ -26,12 +26,32 @@ describe('', () => { jest.clearAllMocks(); }); - it('should render id and name in panel', () => { + it('should render id, name in panel', () => { setup({}); expect(screen.getByText('test')).toBeInTheDocument(); expect(screen.getByText('id1')).toBeInTheDocument(); }); + it('source should be local and should not render connector details when no connector params passed', async () => { + setup({}); + expect(screen.getByText('Local')).toBeInTheDocument(); + expect(screen.queryByText('Connector details')).not.toBeInTheDocument(); + }); + + it('source should be external and should not render nodes details when connector params passed', async () => { + const modelWithConntector = { + ...MODEL, + connector: { + name: 'connector', + }, + }; + setup({ + model: modelWithConntector, + }); + expect(screen.getByText('External')).toBeInTheDocument(); + expect(screen.queryByText('Status by node')).not.toBeInTheDocument(); + }); + it('should call onClose when close panel', async () => { const onClose = jest.fn(); const { user } = setup({ onClose }); @@ -40,7 +60,7 @@ describe('', () => { expect(onClose).toHaveBeenCalled(); }); - it('should render loading when not responding and render partially state when responding', async () => { + it('should render loading when local model not responding and render partially state when responding', async () => { const request = jest.spyOn(APIProvider.getAPI('profile'), 'getModel'); const mockResult = { id: 'model-1-id', @@ -51,9 +71,10 @@ describe('', () => { request.mockResolvedValue(mockResult); setup({}); expect(screen.getByTestId('preview-panel-color-loading-text')).toBeInTheDocument(); - await waitFor(() => - expect(screen.getByText('Partially responding on 2 of 3 nodes')).toBeInTheDocument() - ); + await waitFor(() => { + expect(screen.getByText('Partially responding')).toBeInTheDocument(); + expect(screen.getByText('Responding on 2 of 3 nodes')).toBeInTheDocument(); + }); }); it('should render not responding when no model profile API response', async () => { diff --git a/public/components/preview_panel/connector_details.tsx b/public/components/preview_panel/connector_details.tsx new file mode 100644 index 00000000..08c92afb --- /dev/null +++ b/public/components/preview_panel/connector_details.tsx @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiDescriptionList, + EuiDescriptionListTitle, + EuiTitle, + EuiSpacer, + EuiDescriptionListDescription, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { CopyableText } from '../common'; + +export const ConnectorDetails = (props: { name?: string; id?: string; description?: string }) => { + const { name, id, description } = props; + return ( + <> + + +

Connector details

+
+ + + + + Connector name + {name ? name : '\u2014'} + + + Connector ID + + {id ? ( + + ) : ( + '\u2014' + )} + + + + + Connector description + + {description ? description : '\u2014'} + + + + ); +}; diff --git a/public/components/preview_panel/index.tsx b/public/components/preview_panel/index.tsx index 6f051292..e41e56c1 100644 --- a/public/components/preview_panel/index.tsx +++ b/public/components/preview_panel/index.tsx @@ -15,12 +15,15 @@ import { EuiDescriptionListDescription, EuiSpacer, EuiTextColor, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { APIProvider } from '../../apis/api_provider'; import { useFetcher } from '../../hooks/use_fetcher'; import { NodesTable } from './nodes_table'; import { CopyableText } from '../common'; import { ModelDeploymentProfile } from '../../apis/profile'; +import { ConnectorDetails } from './connector_details'; export interface INode { id: string; @@ -31,6 +34,11 @@ export interface PreviewModel { name: string; id: string; planningWorkerNodes: string[]; + connector?: { + id?: string; + name?: string; + description?: string; + }; } interface Props { @@ -39,7 +47,7 @@ interface Props { } export const PreviewPanel = ({ onClose, model }: Props) => { - const { id, name } = model; + const { id, name, connector } = model; const { data, loading } = useFetcher(APIProvider.getAPI('profile').getModel, id); const nodes = useMemo(() => { if (loading) { @@ -55,33 +63,45 @@ export const PreviewPanel = ({ onClose, model }: Props) => { const respondingStatus = useMemo(() => { if (loading) { - return ( - - Loading... - - ); + return { + overall: ( + + Loading... + + ), + nodes: 'Loading...', + }; } const deployedNodesNum = nodes.filter(({ deployed }) => deployed).length; const targetNodesNum = nodes.length; if (deployedNodesNum === 0) { - return ( - - Not responding on {targetNodesNum} of {targetNodesNum} nodes - - ); + return { + overall: ( + + Not responding + + ), + nodes: `Not responding on ${targetNodesNum} of ${targetNodesNum} nodes`, + }; } if (deployedNodesNum < targetNodesNum) { - return ( - - Partially responding on {deployedNodesNum} of {targetNodesNum} nodes - - ); + return { + overall: ( + + Partially responding + + ), + nodes: `Responding on ${deployedNodesNum} of ${targetNodesNum} nodes`, + }; } - return ( - - Responding on {deployedNodesNum} of {targetNodesNum} nodes - - ); + return { + overall: ( + + Responding + + ), + nodes: `Responding on ${deployedNodesNum} of ${targetNodesNum} nodes`, + }; }, [nodes, loading]); const onCloseFlyout = useCallback(() => { @@ -90,24 +110,42 @@ export const PreviewPanel = ({ onClose, model }: Props) => { return ( - - -

{name}

+ + +

{name}

- - Model ID + + + + Status + + {respondingStatus.overall} + + + + Source + + {connector ? 'External' : 'Local'} + + + + + Model ID - - Model status by node - - {respondingStatus} - - + {connector ? ( + + ) : ( + + )}
); diff --git a/public/components/preview_panel/nodes_table.scss b/public/components/preview_panel/nodes_table.scss index 2406e37c..6a0cc41f 100644 --- a/public/components/preview_panel/nodes_table.scss +++ b/public/components/preview_panel/nodes_table.scss @@ -1,7 +1,7 @@ .ml-nodesTableNodeIdCellText { &:hover{ cursor: pointer; - color: #006BB4; + color: $ouiLinkColor; text-decoration: underline; } } diff --git a/public/components/preview_panel/nodes_table.tsx b/public/components/preview_panel/nodes_table.tsx index 1383fe5b..6355d5dd 100644 --- a/public/components/preview_panel/nodes_table.tsx +++ b/public/components/preview_panel/nodes_table.tsx @@ -14,11 +14,16 @@ import { EuiEmptyPrompt, EuiCopy, EuiText, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiTitle, + EuiSpacer, + EuiDescriptionListDescription, } from '@elastic/eui'; import { INode } from './'; -export function NodesTable(props: { nodes: INode[]; loading: boolean }) { - const { nodes, loading } = props; +export function NodesTable(props: { nodes: INode[]; loading: boolean; nodesStatus: string }) { + const { nodes, loading, nodesStatus } = props; const [sort, setSort] = useState<{ field: keyof INode; direction: Direction }>({ field: 'deployed', direction: 'asc', @@ -108,16 +113,28 @@ export function NodesTable(props: { nodes: INode[]; loading: boolean }) { ); return ( - - columns={columns} - items={items} - sorting={{ sort }} - pagination={pagination} - onChange={handleTableChange} - loading={loading} - noItemsMessage={ - loading ? Loading...} aria-label="loading nodes" /> : undefined - } - /> + <> + + + + +

Status by node

+
+
+ {nodesStatus} +
+ + + columns={columns} + items={items} + sorting={{ sort }} + pagination={pagination} + onChange={handleTableChange} + loading={loading} + noItemsMessage={ + loading ? Loading...} aria-label="loading nodes" /> : undefined + } + /> + ); } diff --git a/public/hooks/index.ts b/public/hooks/index.ts index d9f44fa5..bc774f87 100644 --- a/public/hooks/index.ts +++ b/public/hooks/index.ts @@ -4,4 +4,3 @@ */ export * from './use_fetcher'; -export * from './use_polling_until'; diff --git a/release-notes/opensearch-ml-commons-dashboards.release-notes-2.10.0.0.md b/release-notes/opensearch-ml-commons-dashboards.release-notes-2.10.0.0.md new file mode 100644 index 00000000..95e69396 --- /dev/null +++ b/release-notes/opensearch-ml-commons-dashboards.release-notes-2.10.0.0.md @@ -0,0 +1,21 @@ +## Version 2.10.0.0 Release Notes + +Compatible with OpenSearch 2.10.0 + + +### Features + +* Add source field to distinguish local and external model. ([#239](https://github.com/opensearch-project/ml-commons-dashboards/pull/239)) +* Support external models in deployed model list. ([#248](https://github.com/opensearch-project/ml-commons-dashboards/pull/248)) +* Support external models in model preview panel. ([#252](https://github.com/opensearch-project/ml-commons-dashboards/pull/252)) + + +### Enhancements + +* Mitigate styles to oui variables. ([#227](https://github.com/opensearch-project/ml-commons-dashboards/pull/227)) + +### Bug Fixes + +* Fix no model show up when search a model. ([#238](https://github.com/opensearch-project/ml-commons-dashboards/pull/238)) +* Migrate style to oui attributes and add fallback dash. ([#254](https://github.com/opensearch-project/ml-commons-dashboards/pull/254)) +* Replace dash with em dash. ([#255](https://github.com/opensearch-project/ml-commons-dashboards/pull/255)) \ No newline at end of file diff --git a/release-notes/opensearch-ml-commons-dashboards.release-notes-2.11.0.0.md b/release-notes/opensearch-ml-commons-dashboards.release-notes-2.11.0.0.md new file mode 100644 index 00000000..77f0cdb0 --- /dev/null +++ b/release-notes/opensearch-ml-commons-dashboards.release-notes-2.11.0.0.md @@ -0,0 +1,7 @@ +## Version 2.11.0.0 Release Notes + +Compatible with OpenSearch 2.11.0 + + +### Maintenance +* Increment version to 2.11.0.0 ([#265](https://github.com/opensearch-project/ml-commons-dashboards/pull/265)) \ No newline at end of file diff --git a/release-notes/opensearch-ml-commons-dashboards.release-notes-2.9.0.0.md b/release-notes/opensearch-ml-commons-dashboards.release-notes-2.9.0.0.md new file mode 100644 index 00000000..f1076ec9 --- /dev/null +++ b/release-notes/opensearch-ml-commons-dashboards.release-notes-2.9.0.0.md @@ -0,0 +1,22 @@ +## Version 2.9.0.0 Release Notes + +Compatible with OpenSearch 2.9.0 + + +### Features + +* Exclude remote models in admin UI overview page. ([#225](https://github.com/opensearch-project/ml-commons-dashboards/pull/225)) +* Remove experiment banner and enable plugin by default. ([#229](https://github.com/opensearch-project/ml-commons-dashboards/pull/229)) + + + +### Enhancements + + +* Open external link in new blank page. ([#211](https://github.com/opensearch-project/ml-commons-dashboards/pull/211)) + + +### Bug Fixes + +* Remove refresh interval background in dark mode. ([#209](https://github.com/opensearch-project/ml-commons-dashboards/pull/209)) +* Update to correct machine learning document address for experiment banner and empty screen. ([#210](https://github.com/opensearch-project/ml-commons-dashboards/pull/210)) \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index 5e7324da..617c59e0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -19,6 +19,6 @@ export { MlCommonsPluginSetup, MlCommonsPluginStart } from './types'; export const config: PluginConfigDescriptor = { schema: schema.object({ - enabled: schema.boolean({ defaultValue: false }), + enabled: schema.boolean({ defaultValue: true }), }), }; diff --git a/server/plugin.ts b/server/plugin.ts index 0d728e57..6165357f 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -13,6 +13,7 @@ import { import { MlCommonsPluginSetup, MlCommonsPluginStart } from './types'; import { + connectorRouter, modelVersionRouter, modelAggregateRouter, profileRouter, @@ -40,6 +41,7 @@ export class MlCommonsPlugin implements Plugin { + router.get( + { + path: CONNECTOR_API_ENDPOINT, + validate: {}, + }, + router.handleLegacyErrors(async (context, _req, res) => { + const payload = await ConnectorService.search({ + client: context.core.opensearch.client, + from: 0, + size: 10000, + }); + return res.ok({ body: payload }); + }) + ); + router.get( + { + path: INTERNAL_CONNECTOR_API_ENDPOINT, + validate: {}, + }, + router.handleLegacyErrors(async (context, _req, res) => { + const data = await ConnectorService.getUniqueInternalConnectorNames({ + client: context.core.opensearch.client, + size: 10000, + }); + return res.ok({ body: { data } }); + }) + ); +}; diff --git a/server/routes/constants.ts b/server/routes/constants.ts index 2b635eb1..777c5329 100644 --- a/server/routes/constants.ts +++ b/server/routes/constants.ts @@ -24,3 +24,6 @@ export const TASK_API_ENDPOINT = `${API_PREFIX}/task`; export const MODEL_REPOSITORY_API_ENDPOINT = `${API_PREFIX}/model-repository`; export const MODEL_REPOSITORY_CONFIG_URL_API_ENDPOINT = `${MODEL_REPOSITORY_API_ENDPOINT}/config-url`; + +export const CONNECTOR_API_ENDPOINT = `${API_PREFIX}/connector`; +export const INTERNAL_CONNECTOR_API_ENDPOINT = `${API_PREFIX}/internal-connector`; diff --git a/server/routes/index.ts b/server/routes/index.ts index dc4739ec..b0de84be 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -10,3 +10,4 @@ export { securityRouter } from './security_router'; export { taskRouter } from './task_router'; export { modelRepositoryRouter } from './model_repository_router'; export { modelRouter } from './model_router'; +export { connectorRouter } from './connector_router'; diff --git a/server/routes/model_version_router.ts b/server/routes/model_version_router.ts index 9f8075e9..f06023c1 100644 --- a/server/routes/model_version_router.ts +++ b/server/routes/model_version_router.ts @@ -95,6 +95,7 @@ export const modelVersionRouter = (router: IRouter) => { nameOrId: schema.maybe(schema.string()), versionOrKeyword: schema.maybe(schema.string()), modelIds: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + extra_query: schema.maybe(schema.recordOf(schema.string(), schema.any())), }), }, }, @@ -110,6 +111,7 @@ export const modelVersionRouter = (router: IRouter) => { nameOrId, modelIds, versionOrKeyword, + extra_query: extraQuery, } = request.query; try { const payload = await ModelVersionService.search({ @@ -124,6 +126,7 @@ export const modelVersionRouter = (router: IRouter) => { nameOrId, modelIds: typeof modelIds === 'string' ? [modelIds] : modelIds, versionOrKeyword, + extraQuery, }); return opensearchDashboardsResponseFactory.ok({ body: payload }); } catch (err) { diff --git a/server/services/connector_service.ts b/server/services/connector_service.ts new file mode 100644 index 00000000..96ba00cb --- /dev/null +++ b/server/services/connector_service.ts @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { IScopedClusterClient } from '../../../../src/core/server'; + +import { CONNECTOR_SEARCH_API, MODEL_SEARCH_API } from './utils/constants'; + +export class ConnectorService { + public static async search({ + from, + size, + client, + }: { + client: IScopedClusterClient; + from: number; + size: number; + }) { + let result; + try { + result = await client.asCurrentUser.transport.request({ + method: 'POST', + path: CONNECTOR_SEARCH_API, + body: { + query: { + match_all: {}, + }, + from, + size, + }, + }); + } catch (e) { + if (e instanceof Error && e.message.includes('index_not_found_exception')) { + return { + data: [], + total_connectors: 0, + }; + } + throw e; + } + return { + data: result.body.hits.hits.map(({ _id, _source }) => ({ + id: _id, + ..._source, + })), + total_connectors: result.body.hits.total.value, + }; + } + + public static async getUniqueInternalConnectorNames({ + client, + size, + }: { + client: IScopedClusterClient; + size: number; + }) { + let result; + try { + result = await client.asCurrentUser.transport.request({ + method: 'POST', + path: MODEL_SEARCH_API, + body: { + size: 0, + aggs: { + unique_connector_names: { + terms: { + field: 'connector.name.keyword', + size, + }, + }, + }, + }, + }); + } catch (e) { + if (e instanceof Error && e.message.includes('index_not_found_exception')) { + return []; + } + throw e; + } + return result.body.aggregations.unique_connector_names.buckets.map(({ key }) => key); + } +} diff --git a/server/services/model_version_service.ts b/server/services/model_version_service.ts index 01ec7a33..5f43e086 100644 --- a/server/services/model_version_service.ts +++ b/server/services/model_version_service.ts @@ -82,6 +82,7 @@ export class ModelVersionService { nameOrId?: string; versionOrKeyword?: string; modelIds?: string[]; + extraQuery?: Record; }) { const { body: { hits }, diff --git a/server/services/utils/constants.ts b/server/services/utils/constants.ts index 103e63cf..f0359cb6 100644 --- a/server/services/utils/constants.ts +++ b/server/services/utils/constants.ts @@ -43,4 +43,7 @@ export const CLUSTER = { PREDICT: 'opensearch_mlCommonsPredict', }; +export const CONNECTOR_BASE_API = `${ML_COMMONS_API_PREFIX}/connectors`; +export const CONNECTOR_SEARCH_API = `${CONNECTOR_BASE_API}/_search`; + export const MODEL_INDEX = '.plugins-ml-model'; diff --git a/server/services/utils/model.ts b/server/services/utils/model.ts index 8f48e2af..d3a3ae42 100644 --- a/server/services/utils/model.ts +++ b/server/services/utils/model.ts @@ -14,6 +14,7 @@ export const generateModelSearchQuery = ({ nameOrId, modelIds, versionOrKeyword, + extraQuery, }: { ids?: string[]; algorithms?: string[]; @@ -22,6 +23,7 @@ export const generateModelSearchQuery = ({ nameOrId?: string; versionOrKeyword?: string; modelIds?: string[]; + extraQuery?: Record; }) => ({ bool: { must: [ @@ -72,6 +74,7 @@ export const generateModelSearchQuery = ({ ] : []), ...(modelIds ? [generateTermQuery('model_group_id', modelIds)] : []), + ...(extraQuery ? [extraQuery] : []), ], must_not: { exists: {