[Inference] Fix table responsiveness (#189265)

## Summary

This improves the inference endpoint's table responsiveness and code
clarity. It also fixes a bug where deleting one endpoint and then a
second one would try to delete the same endpoint twice.
<img width="879" alt="Screenshot 2024-07-26 at 12 18 59"
src="https://github.com/user-attachments/assets/003f47ef-6cd4-4244-abe4-141ebf570a98">
<img width="1016" alt="Screenshot 2024-07-26 at 12 18 52"
src="https://github.com/user-attachments/assets/9d7fd119-6c5c-4d5d-b381-68f6189e56e5">
<img width="1410" alt="Screenshot 2024-07-26 at 12 18 37"
src="https://github.com/user-attachments/assets/790fe616-36d4-4a11-9ced-dca325efb753">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sander Philipse 2024-07-30 18:16:33 +08:00 committed by GitHub
parent 7bd6ca647d
commit a9ae3e53f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 304 additions and 405 deletions

View file

@ -9,3 +9,4 @@ export const PLUGIN_ID = 'searchInferenceEndpoints';
export const PLUGIN_NAME = 'InferenceEndpoints';
export const INFERENCE_ENDPOINTS_QUERY_KEY = 'inferenceEndpointsQueryKey';
export const TRAINED_MODEL_STATS_QUERY_KEY = 'trainedModelStats';

View file

@ -119,20 +119,6 @@ export const FORBIDDEN_TO_ACCESS_TRAINED_MODELS = i18n.translate(
}
);
export const COPY_ID_ACTION_LABEL = i18n.translate(
'xpack.searchInferenceEndpoints.actions.copyID',
{
defaultMessage: 'Copy endpoint ID',
}
);
export const COPY_ID_ACTION_SUCCESS = i18n.translate(
'xpack.searchInferenceEndpoints.actions.copyIDSuccess',
{
defaultMessage: 'Inference endpoint ID copied!',
}
);
export const ENDPOINT_ADDED_SUCCESS = i18n.translate(
'xpack.searchInferenceEndpoints.actions.endpointAddedSuccess',
{
@ -153,13 +139,6 @@ export const ENDPOINT_ADDED_SUCCESS_DESCRIPTION = (endpointId: string) =>
values: { endpointId },
});
export const DELETE_ACTION_LABEL = i18n.translate(
'xpack.searchInferenceEndpoints.actions.deleteSingleEndpoint',
{
defaultMessage: 'Delete endpoint',
}
);
export const ENDPOINT = i18n.translate('xpack.searchInferenceEndpoints.endpoint', {
defaultMessage: 'Endpoint',
});

View file

@ -8,7 +8,7 @@
import { renderReactTestingLibraryWithI18n as render } from '@kbn/test-jest-helpers';
import React from 'react';
import { useKibana } from '../../../../../../hooks/use_kibana';
import { useCopyIDAction } from './use_copy_id_action';
import { CopyIDAction } from './copy_id_action';
const mockInferenceEndpoint = {
deployment: 'not_applicable',
@ -35,8 +35,6 @@ Object.defineProperty(navigator, 'clipboard', {
configurable: true,
});
const mockOnActionSuccess = jest.fn();
jest.mock('../../../../../../hooks/use_kibana', () => ({
useKibana: jest.fn(),
}));
@ -53,21 +51,19 @@ const addSuccess = jest.fn();
},
}));
describe('useCopyIDAction hook', () => {
describe('CopyIDAction', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the label with correct text', () => {
const TestComponent = () => {
const { getAction } = useCopyIDAction({ onActionSuccess: mockOnActionSuccess });
const action = getAction(mockInferenceEndpoint);
return <div>{action}</div>;
return <CopyIDAction modelId={mockInferenceEndpoint.endpoint.model_id} />;
};
const { getByTestId } = render(<TestComponent />);
const labelElement = getByTestId('inference-endpoints-action-copy-id-label');
expect(labelElement).toHaveTextContent('Copy endpoint ID');
expect(labelElement).toBeVisible();
});
});

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonIcon, EuiCopy } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useKibana } from '../../../../../../hooks/use_kibana';
interface CopyIDActionProps {
modelId: string;
}
export const CopyIDAction = ({ modelId }: CopyIDActionProps) => {
const {
services: { notifications },
} = useKibana();
const toasts = notifications?.toasts;
return (
<EuiCopy textToCopy={modelId}>
{(copy) => (
<EuiButtonIcon
aria-label={i18n.translate('xpack.searchInferenceEndpoints.actions.copyID', {
defaultMessage: 'Copy inference endpoint ID {modelId}',
values: { modelId },
})}
data-test-subj="inference-endpoints-action-copy-id-label"
iconType="copyClipboard"
onClick={() => {
copy();
toasts?.addSuccess({
title: i18n.translate('xpack.searchInferenceEndpoints.actions.copyIDSuccess', {
defaultMessage: 'Inference endpoint ID {modelId} copied',
values: { modelId },
}),
});
}}
size="s"
/>
)}
</EuiCopy>
);
};

View file

@ -1,44 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiContextMenuItem, EuiCopy, EuiIcon } from '@elastic/eui';
import React from 'react';
import * as i18n from '../../../../../../../common/translations';
import { useKibana } from '../../../../../../hooks/use_kibana';
import { InferenceEndpointUI } from '../../../../types';
import { UseCopyIDActionProps } from '../types';
export const useCopyIDAction = ({ onActionSuccess }: UseCopyIDActionProps) => {
const {
services: { notifications },
} = useKibana();
const toasts = notifications?.toasts;
const getAction = (inferenceEndpoint: InferenceEndpointUI) => {
return (
<EuiCopy textToCopy={inferenceEndpoint.endpoint.model_id} anchorClassName="eui-fullWidth">
{(copy) => (
<EuiContextMenuItem
key="copy"
data-test-subj="inference-endpoints-action-copy-id-label"
icon={<EuiIcon type="copyClipboard" size="m" />}
onClick={() => {
copy();
onActionSuccess();
toasts?.addSuccess({ title: i18n.COPY_ID_ACTION_SUCCESS });
}}
size="s"
>
{i18n.COPY_ID_ACTION_LABEL}
</EuiContextMenuItem>
)}
</EuiCopy>
);
};
return { getAction };
};

View file

@ -18,7 +18,7 @@ describe('ConfirmDeleteEndpointModal', () => {
render(<ConfirmDeleteEndpointModal onCancel={mockOnCancel} onConfirm={mockOnConfirm} />);
});
it('renders the modal with correct texts', () => {
it('renders the modal with correct elements', () => {
expect(screen.getByText(i18n.DELETE_TITLE)).toBeInTheDocument();
expect(screen.getByText(i18n.CONFIRM_DELETE_WARNING)).toBeInTheDocument();
expect(screen.getByText(i18n.CANCEL)).toBeInTheDocument();

View file

@ -22,3 +22,10 @@ export const CONFIRM_DELETE_WARNING = i18n.translate(
'Deleting an active endpoint will cause operations targeting associated semantic_text fields and inference pipelines to fail.',
}
);
export const DELETE_ACTION_LABEL = i18n.translate(
'xpack.searchInferenceEndpoints.actions.deleteSingleEndpoint',
{
defaultMessage: 'Delete endpoint',
}
);

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { useDeleteEndpoint } from '../../../../../../hooks/use_delete_endpoint';
import { InferenceEndpointUI } from '../../../../types';
import { ConfirmDeleteEndpointModal } from './confirm_delete_endpoint';
interface DeleteActionProps {
selectedEndpoint?: InferenceEndpointUI;
}
export const DeleteAction: React.FC<DeleteActionProps> = ({ selectedEndpoint }) => {
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const { mutate: deleteEndpoint } = useDeleteEndpoint(() => setIsModalVisible(false));
const onConfirmDeletion = () => {
if (!selectedEndpoint) {
return;
}
deleteEndpoint({
type: selectedEndpoint.type,
id: selectedEndpoint.endpoint.model_id,
});
};
return (
<>
<EuiButtonIcon
aria-label={i18n.translate('xpack.searchInferenceEndpoints.actions.deleteEndpoint', {
defaultMessage: 'Delete inference endpoint {selectedEndpointName}',
values: { selectedEndpointName: selectedEndpoint?.endpoint.model_id },
})}
key="delete"
iconType="trash"
color="danger"
onClick={() => setIsModalVisible(true)}
/>
{isModalVisible && (
<ConfirmDeleteEndpointModal
onCancel={() => setIsModalVisible(false)}
onConfirm={onConfirmDeletion}
/>
)}
</>
);
};

View file

@ -1,55 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiContextMenuItem, EuiIcon } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import * as i18n from '../../../../../../../common/translations';
import { useDeleteEndpoint } from '../../../../../../hooks/use_delete_endpoint';
import { InferenceEndpointUI } from '../../../../types';
import type { UseActionProps } from '../types';
export const useDeleteAction = ({ onActionSuccess }: UseActionProps) => {
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const [endpointToBeDeleted, setEndpointToBeDeleted] = useState<InferenceEndpointUI | null>(null);
const onCloseModal = useCallback(() => setIsModalVisible(false), []);
const openModal = useCallback(
(selectedEndpoint: InferenceEndpointUI) => {
onActionSuccess();
setIsModalVisible(true);
setEndpointToBeDeleted(selectedEndpoint);
},
[onActionSuccess]
);
const { mutate: deleteEndpoint } = useDeleteEndpoint();
const onConfirmDeletion = useCallback(() => {
onCloseModal();
if (!endpointToBeDeleted) {
return;
}
deleteEndpoint({
type: endpointToBeDeleted.type,
id: endpointToBeDeleted.endpoint.model_id,
});
}, [deleteEndpoint, onCloseModal, endpointToBeDeleted]);
const getAction = (selectedEndpoint: InferenceEndpointUI) => {
return (
<EuiContextMenuItem
key="delete"
icon={<EuiIcon type="trash" size="m" color={'danger'} />}
onClick={() => openModal(selectedEndpoint)}
>
{i18n.DELETE_ACTION_LABEL}
</EuiContextMenuItem>
);
};
return { getAction, isModalVisible, onConfirmDeletion, onCloseModal };
};

View file

@ -1,12 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface UseActionProps {
onActionSuccess: () => void;
}
export type UseCopyIDActionProps = Pick<UseActionProps, 'onActionSuccess'>;

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EuiTableComputedColumnType } from '@elastic/eui';
import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { InferenceEndpointUI } from '../../types';
import { useCopyIDAction } from './actions/copy_id/use_copy_id_action';
import { ConfirmDeleteEndpointModal } from './actions/delete/confirm_delete_endpoint';
import { useDeleteAction } from './actions/delete/use_delete_action';
export const ActionColumn: React.FC<{ interfaceEndpoint: InferenceEndpointUI }> = ({
interfaceEndpoint,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const tooglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const copyIDAction = useCopyIDAction({
onActionSuccess: closePopover,
});
const deleteAction = useDeleteAction({
onActionSuccess: closePopover,
});
const items = [
copyIDAction.getAction(interfaceEndpoint),
deleteAction.getAction(interfaceEndpoint),
];
return (
<>
<EuiPopover
button={
<EuiButtonIcon
onClick={tooglePopover}
iconType="boxesHorizontal"
aria-label={'Actions'}
color="text"
disabled={false}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={items} />
</EuiPopover>
{deleteAction.isModalVisible ? (
<ConfirmDeleteEndpointModal
onCancel={deleteAction.onCloseModal}
onConfirm={deleteAction.onConfirmDeletion}
/>
) : null}
</>
);
};
interface UseBulkActionsReturnValue {
actions: EuiTableComputedColumnType<InferenceEndpointUI>;
}
export const useActions = (): UseBulkActionsReturnValue => {
return {
actions: {
align: 'right',
render: (interfaceEndpoint: InferenceEndpointUI) => {
return <ActionColumn interfaceEndpoint={interfaceEndpoint} />;
},
width: '165px',
},
};
};

View file

@ -15,7 +15,7 @@ interface DeploymentStatusProps {
}
export const DeploymentStatus: React.FC<DeploymentStatusProps> = ({ status }) => {
if (status === DeploymentStatusEnum.notApplicable) {
if (status === DeploymentStatusEnum.notApplicable || !status) {
return null;
}

View file

@ -21,18 +21,18 @@ export interface EndpointInfoProps {
export const EndpointInfo: React.FC<EndpointInfoProps> = ({ endpoint }) => {
return (
<EuiFlexGroup gutterSize="s" direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem>
<strong>{endpoint.model_id}</strong>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexItem css={{ textWrap: 'wrap' }}>
<EndpointModelInfo endpoint={endpoint} />
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const EndpointModelInfo: React.FC<EndpointInfoProps> = ({ endpoint }) => {
const EndpointModelInfo: React.FC<EndpointInfoProps> = ({ endpoint }) => {
const serviceSettings = endpoint.service_settings;
const modelId =
'model_id' in serviceSettings
@ -44,15 +44,10 @@ export const EndpointModelInfo: React.FC<EndpointInfoProps> = ({ endpoint }) =>
const isEligibleForMITBadge = modelId && ELASTIC_MODEL_DEFINITIONS[modelId]?.license === 'MIT';
return (
<EuiFlexGroup gutterSize="s" wrap alignItems="center">
{modelId && (
<EuiFlexItem grow={false}>
<ModelBadge model={modelId} />
</EuiFlexItem>
)}
{isEligibleForMITBadge && (
<EuiFlexItem grow={false}>
<>
<EuiText color="subdued" size="xs">
{modelId && <ModelBadge model={modelId} />}
{isEligibleForMITBadge ? (
<EuiBadge
color="hollow"
iconType="popout"
@ -63,15 +58,10 @@ export const EndpointModelInfo: React.FC<EndpointInfoProps> = ({ endpoint }) =>
>
{i18n.MIT_LICENSE}
</EuiBadge>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="xs">
{endpointModelAtrributes(endpoint)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
) : null}{' '}
{endpointModelAtrributes(endpoint)}
</EuiText>
</>
);
};
@ -141,7 +131,7 @@ function openAIAttributes(endpoint: InferenceAPIConfigResponse) {
function azureOpenAIStudioAttributes(endpoint: InferenceAPIConfigResponse) {
const serviceSettings = endpoint.service_settings;
const provider = 'provider' in serviceSettings ? serviceSettings.provider : undefined;
const provider = 'provider' in serviceSettings ? serviceSettings?.provider : undefined;
const endpointType =
'endpoint_type' in serviceSettings ? serviceSettings.endpoint_type : undefined;
const target = 'target' in serviceSettings ? serviceSettings.target : undefined;

View file

@ -1,80 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils';
import React from 'react';
import type { HorizontalAlignment } from '@elastic/eui';
import { TaskTypes } from '../../../../common/types';
import * as i18n from '../../../../common/translations';
import { useActions } from './render_actions/use_actions';
import { EndpointInfo } from './render_endpoint/endpoint_info';
import { ServiceProvider } from './render_service_provider/service_provider';
import { TaskType } from './render_task_type/task_type';
import { DeploymentStatus } from './render_deployment_status/deployment_status';
import { DeploymentStatusEnum, ServiceProviderKeys } from '../types';
export const useTableColumns = () => {
const { actions } = useActions();
const deploymentAlignment: HorizontalAlignment = 'center';
const TABLE_COLUMNS = [
{
field: 'deployment',
name: '',
render: (deployment: DeploymentStatusEnum) => {
if (deployment != null) {
return <DeploymentStatus status={deployment} />;
}
return null;
},
width: '64px',
align: deploymentAlignment,
},
{
field: 'endpoint',
name: i18n.ENDPOINT,
render: (endpoint: InferenceAPIConfigResponse) => {
if (endpoint != null) {
return <EndpointInfo endpoint={endpoint} />;
}
return null;
},
sortable: true,
},
{
field: 'provider',
name: i18n.SERVICE_PROVIDER,
render: (provider: ServiceProviderKeys) => {
if (provider != null) {
return <ServiceProvider providerKey={provider} />;
}
return null;
},
sortable: false,
width: '185px',
},
{
field: 'type',
name: i18n.TASK_TYPE,
render: (type: TaskTypes) => {
if (type != null) {
return <TaskType type={type} />;
}
return null;
},
sortable: false,
width: '185px',
},
actions,
];
return TABLE_COLUMNS;
};

View file

@ -10,6 +10,8 @@ import { screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import { TabularPage } from './tabular_page';
import { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils';
import { TRAINED_MODEL_STATS_QUERY_KEY } from '../../../common/constants';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const inferenceEndpoints = [
{
@ -43,8 +45,15 @@ jest.mock('../../hooks/use_delete_endpoint', () => ({
}));
describe('When the tabular page is loaded', () => {
const queryClient = new QueryClient();
queryClient.setQueryData([TRAINED_MODEL_STATS_QUERY_KEY], {
trained_model_stats: [{ model_id: '.elser_model_2', deployment_stats: { state: 'started' } }],
});
const wrapper = ({ children }: { children: React.ReactNode }) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
beforeEach(() => {
render(<TabularPage inferenceEndpoints={inferenceEndpoints} />);
render(wrapper({ children: <TabularPage inferenceEndpoints={inferenceEndpoints} /> }));
});
it('should display all model_ids in the table', () => {

View file

@ -5,25 +5,34 @@
* 2.0.
*/
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import {
EuiBasicTable,
EuiBasicTableColumn,
EuiFlexGroup,
EuiFlexItem,
HorizontalAlignment,
} from '@elastic/eui';
import { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils';
import { extractErrorProperties } from '@kbn/ml-error-utils';
import { TaskTypes } from '../../../common/types';
import * as i18n from '../../../common/translations';
import { useTableData } from '../../hooks/use_table_data';
import { FilterOptions } from './types';
import { FilterOptions, InferenceEndpointUI, ServiceProviderKeys } from './types';
import { DeploymentStatusEnum } from './types';
import { useAllInferenceEndpointsState } from '../../hooks/use_all_inference_endpoints_state';
import { EndpointsTable } from './endpoints_table';
import { ServiceProviderFilter } from './filter/service_provider_filter';
import { TaskTypeFilter } from './filter/task_type_filter';
import { TableSearch } from './search/table_search';
import { useTableColumns } from './render_table_columns/table_columns';
import { useKibana } from '../../hooks/use_kibana';
import { DeploymentStatus } from './render_table_columns/render_deployment_status/deployment_status';
import { EndpointInfo } from './render_table_columns/render_endpoint/endpoint_info';
import { ServiceProvider } from './render_table_columns/render_service_provider/service_provider';
import { TaskType } from './render_table_columns/render_task_type/task_type';
import { DeleteAction } from './render_table_columns/render_actions/actions/delete/delete_action';
import { CopyIDAction } from './render_table_columns/render_actions/actions/copy_id/copy_id_action';
interface TabularPageProps {
inferenceEndpoints: InferenceAPIConfigResponse[];
@ -31,16 +40,9 @@ interface TabularPageProps {
export const TabularPage: React.FC<TabularPageProps> = ({ inferenceEndpoints }) => {
const [searchKey, setSearchKey] = React.useState('');
const [deploymentStatus, setDeploymentStatus] = React.useState<
Record<string, DeploymentStatusEnum>
>({});
const { queryParams, setQueryParams, filterOptions, setFilterOptions } =
useAllInferenceEndpointsState();
const {
services: { ml, notifications },
} = useKibana();
const onFilterChangedCallback = useCallback(
(newFilterOptions: Partial<FilterOptions>) => {
setFilterOptions(newFilterOptions);
@ -48,43 +50,76 @@ export const TabularPage: React.FC<TabularPageProps> = ({ inferenceEndpoints })
[setFilterOptions]
);
useEffect(() => {
const fetchDeploymentStatus = async () => {
const trainedModelStats = await ml?.mlApi?.trainedModels.getTrainedModelStats();
if (trainedModelStats) {
const newDeploymentStatus = trainedModelStats?.trained_model_stats.reduce(
(acc, modelStat) => {
if (modelStat.model_id) {
acc[modelStat.model_id] =
modelStat?.deployment_stats?.state === 'started'
? DeploymentStatusEnum.deployed
: DeploymentStatusEnum.notDeployed;
}
return acc;
},
{} as Record<string, DeploymentStatusEnum>
);
setDeploymentStatus(newDeploymentStatus);
}
};
fetchDeploymentStatus().catch((error) => {
const errorObj = extractErrorProperties(error);
notifications?.toasts?.addError(errorObj.message ? new Error(error.message) : error, {
title: i18n.TRAINED_MODELS_STAT_GATHER_FAILED,
});
});
}, [ml, notifications]);
const { paginatedSortedTableData, pagination, sorting } = useTableData(
inferenceEndpoints,
queryParams,
filterOptions,
searchKey,
deploymentStatus
searchKey
);
const tableColumns = useTableColumns();
const tableColumns: Array<EuiBasicTableColumn<InferenceEndpointUI>> = [
{
field: 'deployment',
name: '',
render: (deployment: DeploymentStatusEnum) => <DeploymentStatus status={deployment} />,
align: 'center' as HorizontalAlignment,
width: '64px',
},
{
field: 'endpoint',
name: i18n.ENDPOINT,
render: (endpoint: InferenceAPIConfigResponse) => {
if (endpoint) {
return <EndpointInfo endpoint={endpoint} />;
}
return null;
},
sortable: true,
truncateText: true,
},
{
field: 'provider',
name: i18n.SERVICE_PROVIDER,
render: (provider: ServiceProviderKeys) => {
if (provider) {
return <ServiceProvider providerKey={provider} />;
}
return null;
},
sortable: false,
width: '185px',
},
{
field: 'type',
name: i18n.TASK_TYPE,
render: (type: TaskTypes) => {
if (type) {
return <TaskType type={type} />;
}
return null;
},
sortable: false,
width: '185px',
},
{
actions: [
{
render: (inferenceEndpoint: InferenceEndpointUI) => (
<CopyIDAction modelId={inferenceEndpoint.endpoint.model_id} />
),
},
{
render: (inferenceEndpoint: InferenceEndpointUI) => (
<DeleteAction selectedEndpoint={inferenceEndpoint} />
),
},
],
width: '165px',
},
];
const handleTableChange = useCallback(
({ page, sort }) => {
@ -123,9 +158,10 @@ export const TabularPage: React.FC<TabularPageProps> = ({ inferenceEndpoints })
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EndpointsTable
<EuiBasicTable
columns={tableColumns}
data={paginatedSortedTableData}
itemId="id"
items={paginatedSortedTableData}
onChange={handleTableChange}
pagination={pagination}
sorting={sorting}

View file

@ -15,7 +15,9 @@ import { AddEmptyPrompt } from './empty_prompt/add_empty_prompt';
import { InferenceEndpointsHeader } from './inference_endpoints_header';
export const InferenceEndpoints: React.FC = () => {
const { inferenceEndpoints } = useQueryInferenceEndpoints();
const { data } = useQueryInferenceEndpoints();
const inferenceEndpoints = data || [];
return (
<>

View file

@ -17,7 +17,7 @@ interface MutationArgs {
id: string;
}
export const useDeleteEndpoint = () => {
export const useDeleteEndpoint = (onSuccess?: () => void) => {
const queryClient = useQueryClient();
const { services } = useKibana();
const toasts = services.notifications?.toasts;
@ -32,6 +32,9 @@ export const useDeleteEndpoint = () => {
toasts?.addSuccess({
title: i18n.DELETE_SUCCESS,
});
if (onSuccess) {
onSuccess();
}
},
onError: (error: { body: KibanaServerError }) => {
toasts?.addError(new Error(error.body.message), {

View file

@ -11,13 +11,10 @@ import { APIRoutes } from '../types';
import { useKibana } from './use_kibana';
import { INFERENCE_ENDPOINTS_QUERY_KEY } from '../../common/constants';
export const useQueryInferenceEndpoints = (): {
inferenceEndpoints: InferenceAPIConfigResponse[];
isLoading: boolean;
} => {
export const useQueryInferenceEndpoints = () => {
const { services } = useKibana();
const { data, isLoading } = useQuery({
return useQuery({
queryKey: [INFERENCE_ENDPOINTS_QUERY_KEY],
queryFn: async () => {
const response = await services.http.get<{
@ -27,6 +24,4 @@ export const useQueryInferenceEndpoints = (): {
return response.inference_endpoints;
},
});
return { inferenceEndpoints: data || [], isLoading };
};

View file

@ -10,6 +10,9 @@ import { renderHook } from '@testing-library/react-hooks';
import { QueryParams } from '../components/all_inference_endpoints/types';
import { useTableData } from './use_table_data';
import { INFERENCE_ENDPOINTS_TABLE_PER_PAGE_VALUES } from '../components/all_inference_endpoints/types';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { TRAINED_MODEL_STATS_QUERY_KEY } from '../../common/constants';
const inferenceEndpoints = [
{
@ -59,17 +62,23 @@ const filterOptions = {
type: ['sparse_embedding', 'text_embedding'],
} as any;
const deploymentStatus = {
'.elser_model_2': 'deployed',
lang_ident_model_1: 'not_deployed',
} as any;
const searchKey = 'my';
describe('useTableData', () => {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
beforeEach(() => {
queryClient.setQueryData([TRAINED_MODEL_STATS_QUERY_KEY], {
trained_model_stats: [{ model_id: '.elser_model_2', deployment_stats: { state: 'started' } }],
});
});
it('should return correct pagination', () => {
const { result } = renderHook(() =>
useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey, deploymentStatus)
const { result } = renderHook(
() => useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey),
{ wrapper }
);
expect(result.current.pagination).toEqual({
@ -81,8 +90,9 @@ describe('useTableData', () => {
});
it('should return correct sorting', () => {
const { result } = renderHook(() =>
useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey, deploymentStatus)
const { result } = renderHook(
() => useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey),
{ wrapper }
);
expect(result.current.sorting).toEqual({
@ -94,8 +104,9 @@ describe('useTableData', () => {
});
it('should return correctly sorted data', () => {
const { result } = renderHook(() =>
useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey, deploymentStatus)
const { result } = renderHook(
() => useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey),
{ wrapper }
);
const expectedSortedData = [...inferenceEndpoints].sort((a, b) =>
@ -113,8 +124,9 @@ describe('useTableData', () => {
provider: ['elser'],
type: ['text_embedding'],
} as any;
const { result } = renderHook(() =>
useTableData(inferenceEndpoints, queryParams, filterOptions2, searchKey, deploymentStatus)
const { result } = renderHook(
() => useTableData(inferenceEndpoints, queryParams, filterOptions2, searchKey),
{ wrapper }
);
const filteredData = result.current.sortedTableData;
@ -129,16 +141,18 @@ describe('useTableData', () => {
it('should filter data based on searchKey', () => {
const searchKey2 = 'model-05';
const { result } = renderHook(() =>
useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey2, deploymentStatus)
const { result } = renderHook(
() => useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey2),
{ wrapper }
);
const filteredData = result.current.sortedTableData;
expect(filteredData.every((item) => item.endpoint.model_id.includes(searchKey))).toBeTruthy();
});
it('should update deployment status based on deploymentStatus object', () => {
const { result } = renderHook(() =>
useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey, deploymentStatus)
const { result } = renderHook(
() => useTableData(inferenceEndpoints, queryParams, filterOptions, searchKey),
{ wrapper }
);
const updatedData = result.current.sortedTableData;

View file

@ -20,6 +20,7 @@ import {
ServiceProviderKeys,
} from '../components/all_inference_endpoints/types';
import { DeploymentStatusEnum } from '../components/all_inference_endpoints/types';
import { useTrainedModelStats } from './use_trained_model_stats';
interface UseTableDataReturn {
tableData: InferenceEndpointUI[];
@ -33,9 +34,20 @@ export const useTableData = (
inferenceEndpoints: InferenceAPIConfigResponse[],
queryParams: QueryParams,
filterOptions: FilterOptions,
searchKey: string,
deploymentStatus: Record<string, DeploymentStatusEnum>
searchKey: string
): UseTableDataReturn => {
const { data: trainedModelStats } = useTrainedModelStats();
const deploymentStatus = trainedModelStats?.trained_model_stats.reduce((acc, modelStat) => {
if (modelStat.model_id) {
acc[modelStat.model_id] =
modelStat?.deployment_stats?.state === 'started'
? DeploymentStatusEnum.deployed
: DeploymentStatusEnum.notDeployed;
}
return acc;
}, {} as Record<string, DeploymentStatusEnum>);
const tableData: InferenceEndpointUI[] = useMemo(() => {
let filteredEndpoints = inferenceEndpoints;
@ -62,7 +74,7 @@ export const useTableData = (
if (isElasticService) {
const modelId = endpoint.service_settings?.model_id;
deploymentStatusValue =
modelId && deploymentStatus[modelId] !== undefined
modelId && deploymentStatus?.[modelId]
? deploymentStatus[modelId]
: DeploymentStatusEnum.notDeployable;
}

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { InferenceStatsResponse } from '@kbn/ml-plugin/public/application/services/ml_api_service/trained_models';
import { useKibana } from './use_kibana';
import { TRAINED_MODEL_STATS_QUERY_KEY } from '../../common/constants';
export const useTrainedModelStats = () => {
const { services } = useKibana();
return useQuery({
queryKey: [TRAINED_MODEL_STATS_QUERY_KEY],
queryFn: async () => {
const response = await services.ml?.mlApi?.trainedModels.getTrainedModelStats();
return response || ({ count: 0, trained_model_stats: [] } as InferenceStatsResponse);
},
});
};

View file

@ -4,9 +4,9 @@
"outDir": "target/types",
},
"include": [
"__mocks__/**/*",
"common/**/*",
"public/**/*",
"__mocks__/**/*",
"common/**/*",
"public/**/*",
"server/**/*"
],
"kbn_references": [
@ -29,7 +29,6 @@
"@kbn/doc-links",
"@kbn/console-plugin",
"@kbn/test-jest-helpers",
"@kbn/ml-error-utils",
"@kbn/kibana-utils-plugin"
],
"exclude": [