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 d1ad7c6c..ab107b83 100644 --- a/public/apis/api_provider.ts +++ b/public/apis/api_provider.ts @@ -3,20 +3,24 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Connector } from './connector'; import { Model } from './model'; import { Profile } from './profile'; const apiInstanceStore: { model: Model | undefined; profile: Profile | undefined; + connector: Connector | undefined; } = { model: undefined, profile: undefined, + connector: undefined, }; export class APIProvider { public static getAPI(type: 'model'): Model; public static getAPI(type: 'profile'): Profile; + public static getAPI(type: 'connector'): Connector; public static getAPI(type: keyof typeof apiInstanceStore) { if (apiInstanceStore[type]) { return apiInstanceStore[type]!; @@ -32,9 +36,16 @@ export class APIProvider { apiInstanceStore.profile = newInstance; return newInstance; } + case 'connector': { + const newInstance = new Connector(); + apiInstanceStore.connector = newInstance; + return newInstance; + } } } public static clear() { apiInstanceStore.model = undefined; + apiInstanceStore.profile = undefined; + apiInstanceStore.connector = undefined; } } 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.ts b/public/apis/model.ts index daf0689c..459ce4bb 100644 --- a/public/apis/model.ts +++ b/public/apis/model.ts @@ -16,6 +16,11 @@ export interface ModelSearchItem { current_worker_node_count: number; planning_worker_node_count: number; planning_worker_nodes: string[]; + connector_id?: string; + connector?: { + name: string; + description?: string; + }; } export interface ModelSearchResponse { @@ -30,9 +35,11 @@ export class Model { size: number; states?: MODEL_STATE[]; nameOrId?: string; + extraQuery?: Record; }) { + const { extraQuery, ...restQuery } = query; return InnerHttpProvider.getHttp().get(MODEL_API_ENDPOINT, { - query, + query: extraQuery ? { ...restQuery, extra_query: JSON.stringify(extraQuery) } : restQuery, }); } } diff --git a/public/components/common/options_filter/__tests__/options_filter.test.tsx b/public/components/common/options_filter/__tests__/options_filter.test.tsx new file mode 100644 index 00000000..d11c3eab --- /dev/null +++ b/public/components/common/options_filter/__tests__/options_filter.test.tsx @@ -0,0 +1,168 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { OptionsFilter } from '../options_filter'; +import { render, screen } from '../../../../../test/test_utils'; + +describe('', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should render "Tags" as filter name by default', () => { + render( + {}} + /> + ); + expect(screen.getByText('Tags')).toBeInTheDocument(); + }); + + it('should render Tags with 2 active filter', () => { + render( + {}} + /> + ); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('should render options filter after filter button clicked', async () => { + render( + {}} + /> + ); + expect(screen.queryByText('foo')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Search Tags')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Tags')); + + expect(screen.getByText('foo')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search Tags')).toBeInTheDocument(); + }); + + it('should render passed footer after filter button clicked', async () => { + const { getByText, queryByText } = render( + {}} + footer="footer" + /> + ); + expect(queryByText('footer')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Tags')); + expect(getByText('footer')).toBeInTheDocument(); + }); + + it('should only show "bar" after search', async () => { + render( + {}} + /> + ); + + await userEvent.click(screen.getByText('Tags')); + expect(screen.getByText('foo')).toBeInTheDocument(); + + await userEvent.type(screen.getByPlaceholderText('Search Tags'), 'bAr{enter}'); + expect(screen.queryByText('foo')).not.toBeInTheDocument(); + expect(screen.getByText('bar')).toBeInTheDocument(); + }); + + it('should call onChange with consistent value after option click', async () => { + const onChangeMock = jest.fn(); + const { rerender } = render( + + ); + + expect(onChangeMock).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByText('Tags')); + await userEvent.click(screen.getByText('foo')); + expect(onChangeMock).toHaveBeenCalledWith(['foo']); + onChangeMock.mockClear(); + + rerender( + + ); + + await userEvent.click(screen.getByText('bar')); + expect(onChangeMock).toHaveBeenCalledWith(['foo', 'bar']); + onChangeMock.mockClear(); + + rerender( + + ); + + await userEvent.click(screen.getByText('bar')); + expect(onChangeMock).toHaveBeenCalledWith(['foo']); + onChangeMock.mockClear(); + }); + + it('should call obChange with option.value after option click', async () => { + const onChangeMock = jest.fn(); + render( + + ); + + expect(onChangeMock).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByText('Tags')); + await userEvent.click(screen.getByText('foo')); + expect(onChangeMock).toHaveBeenCalledWith([1]); + }); +}); diff --git a/public/components/common/options_filter/__tests__/options_filter_item.test.tsx b/public/components/common/options_filter/__tests__/options_filter_item.test.tsx new file mode 100644 index 00000000..77a68ee3 --- /dev/null +++ b/public/components/common/options_filter/__tests__/options_filter_item.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { OptionsFilterItem } from '../options_filter_item'; + +import { render, screen } from '../../../../../test/test_utils'; + +describe('', () => { + it('should render passed children and check icon', () => { + render( + {}}> + foo + + ); + expect(screen.getByText('foo')).toBeInTheDocument(); + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); + }); + + it('should call onClick with "foo" after click', async () => { + const onClickMock = jest.fn(); + render( + + foo + + ); + await userEvent.click(screen.getByRole('option')); + expect(onClickMock).toHaveBeenCalledWith('foo'); + }); +}); diff --git a/public/components/common/options_filter/index.ts b/public/components/common/options_filter/index.ts new file mode 100644 index 00000000..fc83688d --- /dev/null +++ b/public/components/common/options_filter/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { OptionsFilter, OptionsFilterProps } from './options_filter'; diff --git a/public/components/common/options_filter/options_filter.tsx b/public/components/common/options_filter/options_filter.tsx new file mode 100644 index 00000000..155fa0d0 --- /dev/null +++ b/public/components/common/options_filter/options_filter.tsx @@ -0,0 +1,122 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + EuiPopover, + EuiPopoverTitle, + EuiFieldSearch, + EuiFilterButton, + EuiPopoverFooter, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { OptionsFilterItem } from './options_filter_item'; + +export interface OptionsFilterProps { + id?: string; + name: string; + searchPlaceholder: string; + searchWidth?: number; + options: Array; + value: T[]; + onChange: (value: T[]) => void; + footer?: React.ReactNode; +} + +export const OptionsFilter = ({ + name, + value, + footer, + options, + searchPlaceholder, + searchWidth, + onChange, + ...restProps +}: OptionsFilterProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [searchText, setSearchText] = useState(); + + const filteredOptions = useMemo( + () => + searchText + ? options.filter((option) => + (typeof option === 'object' ? option.name : option.toString()) + .toLowerCase() + .includes(searchText.toLowerCase()) + ) + : options, + [searchText, options] + ); + + const handleButtonClick = useCallback(() => { + setIsPopoverOpen((prevState) => !prevState); + }, []); + + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const handleFilterItemClick = useCallback( + (clickItemValue: T) => { + onChange( + value.includes(clickItemValue) + ? value.filter((item) => item !== clickItemValue) + : value.concat(clickItemValue) + ); + }, + [value, onChange] + ); + + return ( + 0 ? { hasActiveFilters: true, numActiveFilters: value.length } : {})} + > + {name} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + {...restProps} + > + + + + {filteredOptions.map((item, index) => { + const itemValue = typeof item === 'object' ? item.value : item; + const checked = value.includes(itemValue) ? 'on' : undefined; + return ( + + {typeof item === 'object' ? ( + + {item.prepend && {item.prepend}} + {item.name} + + ) : ( + item + )} + + ); + })} + {footer && {footer}} + + ); +}; diff --git a/public/components/common/options_filter/options_filter_item.tsx b/public/components/common/options_filter/options_filter_item.tsx new file mode 100644 index 00000000..7ca2be92 --- /dev/null +++ b/public/components/common/options_filter/options_filter_item.tsx @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { EuiFilterSelectItem, EuiFilterSelectItemProps } from '@elastic/eui'; + +export interface OptionsFilterItemProps + extends Pick { + value: T; + onClick: (value: T) => void; +} + +export const OptionsFilterItem = ({ + checked, + children, + onClick, + value, +}: OptionsFilterItemProps) => { + const handleClick = useCallback(() => { + onClick(value); + }, [onClick, value]); + return ( + + {children} + + ); +}; diff --git a/public/components/monitoring/index.tsx b/public/components/monitoring/index.tsx index 03d8d3b9..e2cb81da 100644 --- a/public/components/monitoring/index.tsx +++ b/public/components/monitoring/index.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiText, + EuiFilterGroup, } from '@elastic/eui'; import React, { useState, useRef, useCallback } from 'react'; @@ -22,6 +23,8 @@ import { ModelDeploymentItem, ModelDeploymentTable } from './model_deployment_ta import { useMonitoring } from './use_monitoring'; import { ModelStatusFilter } from './model_status_filter'; import { SearchBar } from './search_bar'; +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(); @@ -79,7 +85,7 @@ export const Monitoring = () => {

- Deployed models{' '} + Models{' '} {pageStatus !== 'empty' && ( ({pagination?.totalRecords ?? 0}) @@ -96,7 +102,15 @@ 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 d9f785cd..d3becd73 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 || '-'; + }, + }, { field: 'model_state', name: 'Status', - width: '34.5%', + width: '23.84%', sortable: true, truncateText: true, render: ( @@ -97,64 +120,29 @@ export const ModelDeploymentTable = ({ 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: 'source', - name: 'Source', - width: '7%', - sortable: false, - truncateText: true, - }, - { - field: 'id', - name: 'Model ID', - width: '21%', - 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 ( 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/search_bar.tsx b/public/components/monitoring/search_bar.tsx index 73668c39..6319918d 100644 --- a/public/components/monitoring/search_bar.tsx +++ b/public/components/monitoring/search_bar.tsx @@ -23,11 +23,11 @@ export const SearchBar = ({ onSearch, inputRef }: SearchBarProps) => { ); diff --git a/public/components/monitoring/tests/index.test.tsx b/public/components/monitoring/tests/index.test.tsx index 3dd7d0dd..9fdb0a5c 100644 --- a/public/components/monitoring/tests/index.test.tsx +++ b/public/components/monitoring/tests/index.test.tsx @@ -26,6 +26,8 @@ const setup = ( currentPage: 1, pageSize: 15, sort: { field: 'name', direction: 'asc' }, + connector: [], + source: [], }, pageStatus: 'normal', pagination: { @@ -42,6 +44,9 @@ const setup = ( notRespondingNodesCount: 2, planningNodesCount: 3, planningWorkerNodes: ['node1', 'node2', 'node3'], + connector: { + name: 'Internal Connector 1', + }, }, { id: 'model-2-id', @@ -50,6 +55,9 @@ const setup = ( notRespondingNodesCount: 0, planningNodesCount: 3, planningWorkerNodes: ['node1', 'node2', 'node3'], + connector: { + name: 'External Connector 1', + }, }, { id: 'model-3-id', @@ -60,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(), @@ -158,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); }); }); @@ -193,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 () => { @@ -214,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')); }); @@ -276,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 466fe407..897980ed 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: [ @@ -38,6 +40,9 @@ const setup = (props?: Partial) => { planningNodesCount: 3, planningWorkerNodes: [], source: 'External', + connector: { + name: 'Sagemaker', + }, }, ], pagination: { currentPage: 1, pageSize: 10, totalRecords: 100 }, @@ -107,60 +112,49 @@ 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 display source name at third column', () => { + 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('Source')).toBeInTheDocument(); + expect(within(header).getByText('Connector name')).toBeInTheDocument(); expect(columnContent?.length).toBe(3); const cells = columnContent!; - 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(); + expect(within(cells[0] as HTMLElement).getByText('-')).toBeInTheDocument(); + expect(within(cells[1] as HTMLElement).getByText('-')).toBeInTheDocument(); + expect(within(cells[2] as HTMLElement).getByText('Sagemaker')).toBeInTheDocument(); }); - it('should render Model ID at forth column and copy to clipboard after text clicked', async () => { - const execCommandOrigin = document.execCommand; - document.execCommand = jest.fn(() => true); - + 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('Model ID')).toBeInTheDocument(); + expect(within(header).getByText('Status')).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'); - - document.execCommand = execCommandOrigin; + 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 () => { @@ -225,6 +219,7 @@ describe('', () => { }); it('should call onChange with consistent status sort parameters', async () => { + const statusColumnIndex = 3; const { finalProps, result: { rerender }, @@ -235,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: { @@ -254,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')[3]).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')[3]).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/search_bar.test.tsx b/public/components/monitoring/tests/search_bar.test.tsx index 460fd81f..a7549a56 100644 --- a/public/components/monitoring/tests/search_bar.test.tsx +++ b/public/components/monitoring/tests/search_bar.test.tsx @@ -11,7 +11,7 @@ import { render, screen } from '../../../../test/test_utils'; describe('', () => { it('should render default search bar', () => { render(); - expect(screen.getByPlaceholderText('Search by name or ID')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search by model name or ID')).toBeInTheDocument(); }); it('should call onSearch with 400ms debounce', async () => { @@ -21,7 +21,7 @@ describe('', () => { const onSearch = jest.fn(); render(); - await user.type(screen.getByPlaceholderText('Search by name or ID'), 'foo'); + await user.type(screen.getByPlaceholderText('Search by model name or ID'), 'foo'); expect(onSearch).not.toHaveBeenCalled(); jest.advanceTimersByTime(400); expect(onSearch).toHaveBeenCalled(); diff --git a/public/components/monitoring/tests/use_monitoring.test.ts b/public/components/monitoring/tests/use_monitoring.test.ts index 8c3e1760..3c97e4fa 100644 --- a/public/components/monitoring/tests/use_monitoring.test.ts +++ b/public/components/monitoring/tests/use_monitoring.test.ts @@ -6,9 +6,10 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { Model, ModelSearchResponse } from '../../../apis/model'; +import { Connector } from '../../../apis/connector'; import { useMonitoring } from '../use_monitoring'; -jest.mock('../../../apis/model'); +jest.mock('../../../apis/connector'); const mockEmptyRecords = () => jest.spyOn(Model.prototype, 'search').mockResolvedValueOnce({ @@ -137,9 +138,23 @@ describe('useMonitoring', () => { model_state: '', 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: '', + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + connector: { + name: 'Internal Connector 1', + }, }, ], - total_models: 2, + total_models: 3, }); const { result, waitFor } = renderHook(() => useMonitoring()); @@ -154,7 +169,49 @@ describe('useMonitoring', () => { planningNodesCount: 3, planningWorkerNodes: ['node1', 'node2', 'node3'], }), - expect.objectContaining({ source: 'External' }), + 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(Model.prototype, 'search').mockRestore(); + const searchMock = jest.spyOn(Model.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: '', + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + connector_id: 'not-exists-external-connector-id', + }, + ], + total_models: 1, + }); + const { result, waitFor } = renderHook(() => useMonitoring()); + + await waitFor(() => { + expect(result.current.deployedModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + connector: {}, + }), ]) ); }); @@ -162,6 +219,45 @@ describe('useMonitoring', () => { searchMock.mockRestore(); }); + it('should return empty connector if failed to load all external connectors', async () => { + jest.spyOn(Model.prototype, 'search').mockRestore(); + const getAllExternalConnectorsMock = jest + .spyOn(Connector.prototype, 'getAll') + .mockImplementation(async () => { + throw new Error(); + }); + const searchMock = jest.spyOn(Model.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: '', + model_version: '', + planning_worker_nodes: ['node1', 'node2', 'node3'], + connector_id: 'not-exists-external-connector-id', + }, + ], + total_models: 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()); @@ -213,6 +309,104 @@ describe('useMonitoring', () => { ); }); }); + + 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(Model.prototype.search).toHaveBeenLastCalledWith( + expect.objectContaining({ + extraQuery: { + bool: { + must_not: [ + { + term: { + algorithm: { + value: 'REMOTE', + }, + }, + }, + ], + }, + }, + }) + ) + ); + + result.current.searchBySource(['external']); + await waitFor(() => + expect(Model.prototype.search).toHaveBeenLastCalledWith( + expect.objectContaining({ + extraQuery: { + bool: { + must: [ + { + term: { + algorithm: { + value: 'REMOTE', + }, + }, + }, + ], + }, + }, + }) + ) + ); + + result.current.searchBySource(['external', 'local']); + await waitFor(() => + expect(Model.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(Model.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', () => { @@ -274,6 +468,30 @@ describe('useMonitoring.pageStatus', () => { await waitFor(() => expect(result.current.pageStatus).toBe('reset-filter')); }); + 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()); + + // 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([{ name: 'Sagemaker', ids: [] }]); + await waitFor(() => expect(result.current.pageStatus).toBe('reset-filter')); + }); + it("should return 'empty' if empty data return", async () => { mockEmptyRecords(); const { result, waitFor } = renderHook(() => useMonitoring()); diff --git a/public/components/monitoring/use_monitoring.ts b/public/components/monitoring/use_monitoring.ts index 027f86a4..b792255a 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_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_STATE.partiallyLoaded; } }); + let externalConnectorsData: GetAllConnectorResponse; + try { + externalConnectorsData = await APIProvider.getAPI('connector').getAll(); + } catch (_e) { + externalConnectorsData = { data: [], total_connectors: 0 }; + } const result = await APIProvider.getAPI('model').search({ from: (params.currentPage - 1) * params.pageSize, size: params.pageSize, @@ -47,7 +108,27 @@ const fetchDeployedModels = async (params: Params) => { ? [MODEL_STATE.loadFailed, MODEL_STATE.loaded, MODEL_STATE.partiallyLoaded] : 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_models / params.pageSize); return { pagination: { @@ -64,6 +145,7 @@ const fetchDeployedModels = async (params: Params) => { planning_worker_node_count: planningCount, planning_worker_nodes: planningWorkerNodes, algorithm, + ...rest }) => { return { id, @@ -75,10 +157,13 @@ const fetchDeployedModels = async (params: Params) => { ? planningCount - workerCount : undefined, planningWorkerNodes, - source: algorithm === 'REMOTE' ? 'External' : 'Local', + connector: rest.connector_id + ? externalConnectorMap[rest.connector_id] || {} + : rest.connector, }; } ), + allExternalConnectors: externalConnectorsData.data, }; }; @@ -87,6 +172,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); @@ -115,6 +202,8 @@ export const useMonitoring = () => { currentPage: previousValue.currentPage, pageSize: previousValue.pageSize, sort: previousValue.sort, + source: [], + connector: [], })); }, []); @@ -134,6 +223,22 @@ export const useMonitoring = () => { })); }, []); + const searchBySource = useCallback((source: Params['source']) => { + setParams((previousValue) => ({ + ...previousValue, + source, + currentPage: 1, + })); + }, []); + + const searchByConnector = useCallback((connector: Params['connector']) => { + setParams((previousValue) => ({ + ...previousValue, + connector, + currentPage: 1, + })); + }, []); + const handleTableChange = useCallback( (criteria: { pagination?: { currentPage: number; pageSize: number }; @@ -166,9 +271,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__/preview_panel.test.tsx b/public/components/preview_panel/__tests__/preview_panel.test.tsx index 08ca1584..fed8a902 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'; @@ -13,7 +13,9 @@ const MODEL = { id: 'id1', name: 'test', planningWorkerNodes: ['node-1', 'node-2', 'node-3'], - source: 'External', + connector: { + name: 'Connector', + }, }; function setup({ model = MODEL, onClose = jest.fn() }) { diff --git a/public/components/preview_panel/index.tsx b/public/components/preview_panel/index.tsx index 354dc2a9..47b26ea5 100644 --- a/public/components/preview_panel/index.tsx +++ b/public/components/preview_panel/index.tsx @@ -31,7 +31,11 @@ export interface PreviewModel { name: string; id: string; planningWorkerNodes: string[]; - source: string; + connector?: { + id?: string; + name?: string; + description?: string; + }; } interface Props { @@ -40,7 +44,7 @@ interface Props { } export const PreviewPanel = ({ onClose, model }: Props) => { - const { id, name, source } = model; + const { id, name, connector } = model; const { data, loading } = useFetcher(APIProvider.getAPI('profile').getModel, id); const nodes = useMemo(() => { if (loading) { @@ -103,7 +107,9 @@ export const PreviewPanel = ({ onClose, model }: Props) => { Source - {source} + + {connector ? 'External' : 'Local'} + Model status by node {respondingStatus} 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/server/plugin.ts b/server/plugin.ts index d7abc6f6..5e968077 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -12,7 +12,7 @@ import { } from '../../../src/core/server'; import { MlCommonsPluginSetup, MlCommonsPluginStart } from './types'; -import { modelRouter, profileRouter } from './routes'; +import { connectorRouter, modelRouter, profileRouter } from './routes'; export class MlCommonsPlugin implements Plugin { private readonly logger: Logger; @@ -27,6 +27,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 21c7c1e7..cca5b22b 100644 --- a/server/routes/constants.ts +++ b/server/routes/constants.ts @@ -8,3 +8,5 @@ export const API_PREFIX = '/api/ml-commons'; export const MODEL_API_ENDPOINT = `${API_PREFIX}/model`; export const PROFILE_API_ENDPOINT = `${API_PREFIX}/profile`; export const DEPLOYED_MODEL_PROFILE_API_ENDPOINT = `${PROFILE_API_ENDPOINT}/deployed-model`; +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 888620b2..7942ad64 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -5,3 +5,4 @@ export { modelRouter } from './model_router'; export { profileRouter } from './profile_router'; +export { connectorRouter } from './connector_router'; diff --git a/server/routes/model_router.ts b/server/routes/model_router.ts index 822df4c2..0adbd537 100644 --- a/server/routes/model_router.ts +++ b/server/routes/model_router.ts @@ -42,11 +42,12 @@ export const modelRouter = (router: IRouter) => { ), states: schema.maybe(schema.oneOf([schema.arrayOf(modelStateSchema), modelStateSchema])), nameOrId: schema.maybe(schema.string()), + extra_query: schema.maybe(schema.recordOf(schema.string(), schema.any())), }), }, }, async (context, request) => { - const { from, size, sort, states, nameOrId } = request.query; + const { from, size, sort, states, nameOrId, extra_query: extraQuery } = request.query; try { const payload = await ModelService.search({ client: context.core.opensearch.client, @@ -55,6 +56,7 @@ export const modelRouter = (router: IRouter) => { sort: typeof sort === 'string' ? [sort] : sort, states: typeof states === 'string' ? [states] : states, nameOrId, + 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_service.ts b/server/services/model_service.ts index 2b98dfdf..062d53dd 100644 --- a/server/services/model_service.ts +++ b/server/services/model_service.ts @@ -42,6 +42,7 @@ export class ModelService { size: number; sort?: ModelSearchSort[]; states?: MODEL_STATE[]; + extraQuery?: Record; nameOrId?: string; }) { const { diff --git a/server/services/utils/constants.ts b/server/services/utils/constants.ts index 16681eba..bda18b7e 100644 --- a/server/services/utils/constants.ts +++ b/server/services/utils/constants.ts @@ -23,4 +23,7 @@ export const PROFILE_BASE_API = `${API_ROUTE_PREFIX}/profile`; export const MODEL_BASE_API = `${API_ROUTE_PREFIX}/models`; export const MODEL_SEARCH_API = `${MODEL_BASE_API}/_search`; +export const CONNECTOR_BASE_API = `${API_ROUTE_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 f7b585c4..5196445a 100644 --- a/server/services/utils/model.ts +++ b/server/services/utils/model.ts @@ -9,9 +9,11 @@ import { generateTermQuery } from './query'; export const generateModelSearchQuery = ({ states, nameOrId, + extraQuery, }: { states?: MODEL_STATE[]; nameOrId?: string; + extraQuery?: Record; }) => ({ bool: { must: [ @@ -32,6 +34,7 @@ export const generateModelSearchQuery = ({ }, ] : []), + ...(extraQuery ? [extraQuery] : []), ], must_not: { exists: {