mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
7bd6ca647d
commit
a9ae3e53f1
23 changed files with 304 additions and 405 deletions
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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'>;
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
};
|
|
@ -15,7 +15,7 @@ interface DeploymentStatusProps {
|
|||
}
|
||||
|
||||
export const DeploymentStatus: React.FC<DeploymentStatusProps> = ({ status }) => {
|
||||
if (status === DeploymentStatusEnum.notApplicable) {
|
||||
if (status === DeploymentStatusEnum.notApplicable || !status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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', () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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), {
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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": [
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue