mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# Backport This will backport the following commits from `main` to `8.12`: - [[Enterprise Search] Split details panel from model selection list (#173434)](https://github.com/elastic/kibana/pull/173434) <!--- Backport version: 8.9.7 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Adam Demjen","email":"demjened@gmail.com"},"sourceCommit":{"committedDate":"2023-12-19T22:37:29Z","message":"[Enterprise Search] Split details panel from model selection list (#173434)\n\n## Summary\r\n\r\nIn this PR the ML model selection list is being split into a simplified\r\nlist and a details panel for the selected model. This change provides\r\nproblem-free keyboard navigation and a cleaner look.\r\n\r\n<img width=\"1206\" alt=\"Screenshot 2023-12-18 at 09 34 40\"\r\nsrc=\"815fe106
-cc7b-4bab-8a03-0bda6ec6459e\">\r\n\r\nThe Start button in the model panel now appears and works for any model,\r\nnot just ELSER/E5.\r\n\r\nIn order to make the model status labels and actions consistent, some\r\nlabels have been renamed:\r\n- \"Downloaded\" -> \"Deployed\"\r\n- \"Downloading\" -> \"Deploying\"\r\n\r\nThe fetch logic is now returning \"Not deployed\" for a downloaded curated\r\nmodel. Any model (curated or 3rd party) in this state can be started\r\nwith the Start button.\r\n\r\n### Checklist\r\n\r\nDelete any items that are not applicable to this PR.\r\n\r\n- [x] Any text added follows [EUI's writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing), uses\r\nsentence case text and includes [i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n- [x] Any UI touched in this PR is usable by keyboard only (learn more\r\nabout [keyboard accessibility](https://webaim.org/techniques/keyboard/))\r\n- [x] Any UI touched in this PR does not create any new axe failures\r\n(run axe in browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n- [x] This renders correctly on smaller devices using a responsive\r\nlayout. (You can test this [in your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"641177e2fed06a4c4d44eb6ba8e2bea2310f5404","branchLabelMapping":{"^v8.13.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:EnterpriseSearch","v8.12.0","a11yReviewNeeded","v8.13.0"],"number":173434,"url":"https://github.com/elastic/kibana/pull/173434","mergeCommit":{"message":"[Enterprise Search] Split details panel from model selection list (#173434)\n\n## Summary\r\n\r\nIn this PR the ML model selection list is being split into a simplified\r\nlist and a details panel for the selected model. This change provides\r\nproblem-free keyboard navigation and a cleaner look.\r\n\r\n<img width=\"1206\" alt=\"Screenshot 2023-12-18 at 09 34 40\"\r\nsrc=\"815fe106
-cc7b-4bab-8a03-0bda6ec6459e\">\r\n\r\nThe Start button in the model panel now appears and works for any model,\r\nnot just ELSER/E5.\r\n\r\nIn order to make the model status labels and actions consistent, some\r\nlabels have been renamed:\r\n- \"Downloaded\" -> \"Deployed\"\r\n- \"Downloading\" -> \"Deploying\"\r\n\r\nThe fetch logic is now returning \"Not deployed\" for a downloaded curated\r\nmodel. Any model (curated or 3rd party) in this state can be started\r\nwith the Start button.\r\n\r\n### Checklist\r\n\r\nDelete any items that are not applicable to this PR.\r\n\r\n- [x] Any text added follows [EUI's writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing), uses\r\nsentence case text and includes [i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n- [x] Any UI touched in this PR is usable by keyboard only (learn more\r\nabout [keyboard accessibility](https://webaim.org/techniques/keyboard/))\r\n- [x] Any UI touched in this PR does not create any new axe failures\r\n(run axe in browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n- [x] This renders correctly on smaller devices using a responsive\r\nlayout. (You can test this [in your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"641177e2fed06a4c4d44eb6ba8e2bea2310f5404"}},"sourceBranch":"main","suggestedTargetBranches":["8.12"],"targetPullRequestStates":[{"branch":"8.12","label":"v8.12.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.13.0","labelRegex":"^v8.13.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/173434","number":173434,"mergeCommit":{"message":"[Enterprise Search] Split details panel from model selection list (#173434)\n\n## Summary\r\n\r\nIn this PR the ML model selection list is being split into a simplified\r\nlist and a details panel for the selected model. This change provides\r\nproblem-free keyboard navigation and a cleaner look.\r\n\r\n<img width=\"1206\" alt=\"Screenshot 2023-12-18 at 09 34 40\"\r\nsrc=\"815fe106
-cc7b-4bab-8a03-0bda6ec6459e\">\r\n\r\nThe Start button in the model panel now appears and works for any model,\r\nnot just ELSER/E5.\r\n\r\nIn order to make the model status labels and actions consistent, some\r\nlabels have been renamed:\r\n- \"Downloaded\" -> \"Deployed\"\r\n- \"Downloading\" -> \"Deploying\"\r\n\r\nThe fetch logic is now returning \"Not deployed\" for a downloaded curated\r\nmodel. Any model (curated or 3rd party) in this state can be started\r\nwith the Start button.\r\n\r\n### Checklist\r\n\r\nDelete any items that are not applicable to this PR.\r\n\r\n- [x] Any text added follows [EUI's writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing), uses\r\nsentence case text and includes [i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n- [x] Any UI touched in this PR is usable by keyboard only (learn more\r\nabout [keyboard accessibility](https://webaim.org/techniques/keyboard/))\r\n- [x] Any UI touched in this PR does not create any new axe failures\r\n(run axe in browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n- [x] This renders correctly on smaller devices using a responsive\r\nlayout. (You can test this [in your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"641177e2fed06a4c4d44eb6ba8e2bea2310f5404"}}]}] BACKPORT--> Co-authored-by: Adam Demjen <demjened@gmail.com>
This commit is contained in:
parent
0f9c1ea303
commit
f7e2d6e2d0
13 changed files with 615 additions and 346 deletions
|
@ -19,7 +19,6 @@ import {
|
|||
EuiTabbedContentTab,
|
||||
EuiTitle,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
@ -131,27 +130,15 @@ export const ConfigurePipeline: React.FC = () => {
|
|||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
<EuiSpacer size="s" />
|
||||
<EuiTitle size="xxxs">
|
||||
<h6>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.titleSelectTrainedModel',
|
||||
{ defaultMessage: 'Select a trained ML Model' }
|
||||
)}
|
||||
</h6>
|
||||
</EuiTitle>
|
||||
{formErrors.modelStatus !== undefined && (
|
||||
<>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiText size="xs">
|
||||
<p>
|
||||
<EuiTextColor color="danger">{formErrors.modelStatus}</EuiTextColor>
|
||||
</p>
|
||||
</EuiText>
|
||||
</>
|
||||
)}
|
||||
<EuiSpacer size="xs" />
|
||||
<ModelSelect />
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.titleSelectTrainedModel',
|
||||
{ defaultMessage: 'Select a trained ML Model' }
|
||||
)}
|
||||
>
|
||||
<ModelSelect />
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
</>
|
||||
),
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
|
||||
import { LicenseBadge, LicenseBadgeProps } from './license_badge';
|
||||
|
||||
const DEFAULT_PROPS: LicenseBadgeProps = {
|
||||
licenseType: 'mit',
|
||||
modelDetailsPageUrl: 'https://my-model.ai',
|
||||
};
|
||||
|
||||
describe('LicenseBadge', () => {
|
||||
it('renders with link if URL is present', () => {
|
||||
const wrapper = shallow(
|
||||
<LicenseBadge
|
||||
licenseType={DEFAULT_PROPS.licenseType}
|
||||
modelDetailsPageUrl={DEFAULT_PROPS.modelDetailsPageUrl}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(EuiLink)).toHaveLength(1);
|
||||
});
|
||||
it('renders without link if URL is not present', () => {
|
||||
const wrapper = shallow(<LicenseBadge licenseType={DEFAULT_PROPS.licenseType} />);
|
||||
expect(wrapper.find(EuiLink)).toHaveLength(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
|
||||
import { EuiBadge, EuiLink } from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export interface LicenseBadgeProps {
|
||||
licenseType: string;
|
||||
modelDetailsPageUrl?: string;
|
||||
}
|
||||
|
||||
export const LicenseBadge: React.FC<LicenseBadgeProps> = ({ licenseType, modelDetailsPageUrl }) => {
|
||||
const licenseLabel = i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelectOption.licenseBadge.label',
|
||||
{
|
||||
defaultMessage: 'License: {licenseType}',
|
||||
values: {
|
||||
licenseType,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiBadge color="hollow">
|
||||
{modelDetailsPageUrl ? (
|
||||
<EuiLink target="_blank" href={modelDetailsPageUrl}>
|
||||
{licenseLabel}
|
||||
</EuiLink>
|
||||
) : (
|
||||
<p>{licenseLabel}</p>
|
||||
)}
|
||||
</EuiBadge>
|
||||
);
|
||||
};
|
|
@ -119,7 +119,7 @@ export interface MLInferencePipelineOption {
|
|||
indexFields: string[];
|
||||
}
|
||||
|
||||
interface MLInferenceProcessorsActions {
|
||||
export interface MLInferenceProcessorsActions {
|
||||
addSelectedFieldsToMapping: (isTextExpansionModelSelected: boolean) => {
|
||||
isTextExpansionModelSelected: boolean;
|
||||
};
|
||||
|
|
|
@ -11,13 +11,23 @@ import React from 'react';
|
|||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiSelectable } from '@elastic/eui';
|
||||
import { EuiSelectable, EuiText } from '@elastic/eui';
|
||||
|
||||
import { ModelSelect } from './model_select';
|
||||
import { MlModel, MlModelDeploymentState } from '../../../../../../../common/types/ml';
|
||||
|
||||
import { LicenseBadge } from './license_badge';
|
||||
import {
|
||||
DeployModelButton,
|
||||
ModelSelect,
|
||||
NoModelSelected,
|
||||
SelectedModel,
|
||||
StartModelButton,
|
||||
} from './model_select';
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
addInferencePipelineModal: {
|
||||
configuration: {},
|
||||
indexName: 'my-index',
|
||||
},
|
||||
selectableModels: [
|
||||
{
|
||||
|
@ -27,8 +37,23 @@ const DEFAULT_VALUES = {
|
|||
modelId: 'model_2',
|
||||
},
|
||||
],
|
||||
indexName: 'my-index',
|
||||
};
|
||||
const DEFAULT_MODEL: MlModel = {
|
||||
modelId: 'model_1',
|
||||
type: 'ner',
|
||||
title: 'Model 1',
|
||||
description: 'Model 1 description',
|
||||
licenseType: 'elastic',
|
||||
modelDetailsPageUrl: 'https://my-model.ai',
|
||||
deploymentState: MlModelDeploymentState.NotDeployed,
|
||||
startTime: 0,
|
||||
targetAllocationCount: 0,
|
||||
nodeAllocationCount: 0,
|
||||
threadsPerAllocation: 0,
|
||||
isPlaceholder: false,
|
||||
hasStats: false,
|
||||
};
|
||||
|
||||
const MOCK_ACTIONS = {
|
||||
setInferencePipelineConfiguration: jest.fn(),
|
||||
};
|
||||
|
@ -150,4 +175,74 @@ describe('ModelSelect', () => {
|
|||
})
|
||||
);
|
||||
});
|
||||
it('renders selected model panel if a model is selected', () => {
|
||||
setMockValues({
|
||||
...DEFAULT_VALUES,
|
||||
addInferencePipelineModal: {
|
||||
configuration: {
|
||||
...DEFAULT_VALUES.addInferencePipelineModal.configuration,
|
||||
modelID: 'model_2',
|
||||
},
|
||||
},
|
||||
selectedModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
const wrapper = shallow(<ModelSelect />);
|
||||
expect(wrapper.find(SelectedModel)).toHaveLength(1);
|
||||
expect(wrapper.find(NoModelSelected)).toHaveLength(0);
|
||||
});
|
||||
it('renders no model selected panel if no model is selected', () => {
|
||||
setMockValues(DEFAULT_VALUES);
|
||||
|
||||
const wrapper = shallow(<ModelSelect />);
|
||||
expect(wrapper.find(SelectedModel)).toHaveLength(0);
|
||||
expect(wrapper.find(NoModelSelected)).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('SelectedModel', () => {
|
||||
it('renders with license badge if present', () => {
|
||||
const wrapper = shallow(<SelectedModel {...DEFAULT_MODEL} />);
|
||||
expect(wrapper.find(LicenseBadge)).toHaveLength(1);
|
||||
});
|
||||
it('renders without license badge if not present', () => {
|
||||
const props = {
|
||||
...DEFAULT_MODEL,
|
||||
licenseType: undefined,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SelectedModel {...props} />);
|
||||
expect(wrapper.find(LicenseBadge)).toHaveLength(0);
|
||||
});
|
||||
it('renders with description if present', () => {
|
||||
const wrapper = shallow(<SelectedModel {...DEFAULT_MODEL} />);
|
||||
expect(wrapper.find(EuiText)).toHaveLength(1);
|
||||
});
|
||||
it('renders without description if not present', () => {
|
||||
const props = {
|
||||
...DEFAULT_MODEL,
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SelectedModel {...props} />);
|
||||
expect(wrapper.find(EuiText)).toHaveLength(0);
|
||||
});
|
||||
it('renders deploy button for a model placeholder', () => {
|
||||
const props = {
|
||||
...DEFAULT_MODEL,
|
||||
isPlaceholder: true,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SelectedModel {...props} />);
|
||||
expect(wrapper.find(DeployModelButton)).toHaveLength(1);
|
||||
});
|
||||
it('renders start button for a downloaded model', () => {
|
||||
const props = {
|
||||
...DEFAULT_MODEL,
|
||||
deploymentState: MlModelDeploymentState.NotDeployed,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SelectedModel {...props} />);
|
||||
expect(wrapper.find(StartModelButton)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,31 +5,297 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useActions, useValues } from 'kea';
|
||||
|
||||
import { EuiSelectable, useIsWithinMaxBreakpoint } from '@elastic/eui';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiLoadingSpinner,
|
||||
EuiPanel,
|
||||
EuiScreenReaderLive,
|
||||
EuiSelectable,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
EuiTitle,
|
||||
useEuiTheme,
|
||||
useIsWithinMaxBreakpoint,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { MlModel } from '../../../../../../../common/types/ml';
|
||||
import { IndexNameLogic } from '../../index_name_logic';
|
||||
import { IndexViewLogic } from '../../index_view_logic';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { MLInferenceLogic } from './ml_inference_logic';
|
||||
import { MlModel, MlModelDeploymentState } from '../../../../../../../common/types/ml';
|
||||
|
||||
import { LicenseBadge } from './license_badge';
|
||||
import { ModelSelectLogic } from './model_select_logic';
|
||||
import { ModelSelectOption, ModelSelectOptionProps } from './model_select_option';
|
||||
import { normalizeModelName } from './utils';
|
||||
|
||||
export const DeployModelButton: React.FC<{
|
||||
onClick: () => void;
|
||||
modelId: string;
|
||||
disabled: boolean;
|
||||
}> = ({ onClick, modelId, disabled }) => {
|
||||
return (
|
||||
<EuiButton
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
color="primary"
|
||||
iconType="download"
|
||||
size="s"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.deployButton.ariaLabel',
|
||||
{
|
||||
defaultMessage: 'Deploy {modelId} model',
|
||||
values: {
|
||||
modelId,
|
||||
},
|
||||
}
|
||||
)}
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.deployButton.label',
|
||||
{
|
||||
defaultMessage: 'Deploy',
|
||||
}
|
||||
)}
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelDeployingButton: React.FC = () => {
|
||||
return (
|
||||
<EuiButton disabled color="primary" size="s">
|
||||
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.deployingButton.label',
|
||||
{
|
||||
defaultMessage: 'Deploying',
|
||||
}
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const StartModelButton: React.FC<{
|
||||
onClick: () => void;
|
||||
modelId: string;
|
||||
disabled: boolean;
|
||||
}> = ({ onClick, modelId, disabled }) => {
|
||||
return (
|
||||
<EuiButton
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
color="success"
|
||||
iconType="play"
|
||||
size="s"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.startButton.ariaLabel',
|
||||
{
|
||||
defaultMessage: 'Start {modelId} model',
|
||||
values: {
|
||||
modelId,
|
||||
},
|
||||
}
|
||||
)}
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.startButton.label',
|
||||
{
|
||||
defaultMessage: 'Start',
|
||||
}
|
||||
)}
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelStartingButton: React.FC = () => {
|
||||
return (
|
||||
<EuiButton disabled color="success" size="s">
|
||||
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.startingButton.label',
|
||||
{
|
||||
defaultMessage: 'Starting',
|
||||
}
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoModelSelected: React.FC = () => (
|
||||
<EuiPanel
|
||||
color="subdued"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<EuiText textAlign="center" color="subdued" size="s">
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.noModelSelectedPanel.text',
|
||||
{ defaultMessage: 'Select an available model to add to your inference pipeline' }
|
||||
)}
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
);
|
||||
|
||||
export const SelectedModel: React.FC<MlModel> = (model) => {
|
||||
const { createModel, startModel } = useActions(ModelSelectLogic);
|
||||
const { areActionButtonsDisabled } = useValues(ModelSelectLogic);
|
||||
|
||||
const getSelectedModelAnnouncement = (selectedModel: MlModel) =>
|
||||
selectedModel.isPlaceholder
|
||||
? i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.selectedModelNotDeployedAnnouncement',
|
||||
{
|
||||
defaultMessage: '{modelId} model selected but not deployed',
|
||||
values: {
|
||||
modelId: selectedModel.modelId,
|
||||
},
|
||||
}
|
||||
)
|
||||
: selectedModel.deploymentState === MlModelDeploymentState.NotDeployed
|
||||
? i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.selectedModelNotStartedAnnouncement',
|
||||
{
|
||||
defaultMessage: '{modelId} model selected but not started',
|
||||
values: {
|
||||
modelId: selectedModel.modelId,
|
||||
},
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.selectedModelAnnouncement',
|
||||
{
|
||||
defaultMessage: '{modelId} model selected',
|
||||
values: {
|
||||
modelId: selectedModel.modelId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPanel color="subdued" title="Selected model">
|
||||
<EuiScreenReaderLive>{getSelectedModelAnnouncement(model)}</EuiScreenReaderLive>
|
||||
<EuiPanel>
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xs">
|
||||
<h4>{model.title}</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiTextColor color="subdued">{model.modelId}</EuiTextColor>
|
||||
</EuiFlexItem>
|
||||
{model.description && (
|
||||
<EuiFlexItem>
|
||||
<EuiText size="xs">{model.description}</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{model.licenseType && (
|
||||
<EuiFlexItem grow={false}>
|
||||
{/* Wrap in a span to prevent the badge from growing to a whole row on mobile */}
|
||||
<span>
|
||||
<LicenseBadge
|
||||
licenseType={model.licenseType}
|
||||
modelDetailsPageUrl={model.modelDetailsPageUrl}
|
||||
/>
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{(model.isPlaceholder ||
|
||||
[
|
||||
MlModelDeploymentState.Downloading,
|
||||
MlModelDeploymentState.NotDeployed,
|
||||
MlModelDeploymentState.Starting,
|
||||
].includes(model.deploymentState)) && (
|
||||
<>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
<EuiFlexItem grow={false} aria-live="polite" aria-atomic="false">
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
{model.isPlaceholder ? (
|
||||
<>
|
||||
<EuiFlexItem grow={false}>
|
||||
<DeployModelButton
|
||||
onClick={() => createModel(model.modelId)}
|
||||
modelId={model.modelId}
|
||||
disabled={areActionButtonsDisabled}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText size="xs">
|
||||
<p>
|
||||
<EuiTextColor color="danger">
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelect.modelNotDeployedError',
|
||||
{ defaultMessage: 'Model must be deployed before use.' }
|
||||
)}
|
||||
</EuiTextColor>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
) : model.deploymentState === MlModelDeploymentState.Downloading ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ModelDeployingButton />
|
||||
</EuiFlexItem>
|
||||
) : model.deploymentState === MlModelDeploymentState.NotDeployed ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<StartModelButton
|
||||
onClick={() => startModel(model.modelId)}
|
||||
modelId={model.modelId}
|
||||
disabled={areActionButtonsDisabled}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
) : model.deploymentState === MlModelDeploymentState.Starting ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ModelStartingButton />
|
||||
</EuiFlexItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelSelect: React.FC = () => {
|
||||
const { indexName } = useValues(IndexNameLogic);
|
||||
const { ingestionMethod } = useValues(IndexViewLogic);
|
||||
const {
|
||||
addInferencePipelineModal: { configuration },
|
||||
} = useValues(MLInferenceLogic);
|
||||
const { selectableModels, isLoading } = useValues(ModelSelectLogic);
|
||||
const { setInferencePipelineConfiguration } = useActions(MLInferenceLogic);
|
||||
addInferencePipelineModal: { configuration, indexName },
|
||||
ingestionMethod,
|
||||
isLoading,
|
||||
selectableModels,
|
||||
selectedModel,
|
||||
} = useValues(ModelSelectLogic);
|
||||
const { setInferencePipelineConfiguration } = useActions(ModelSelectLogic);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const { modelID, pipelineName, isPipelineNameUserSupplied } = configuration;
|
||||
const rowHeight = useIsWithinMaxBreakpoint('s') ? euiTheme.base * 8 : euiTheme.base * 6;
|
||||
const maxVisibleOptions = 4.5;
|
||||
const [listHeight, setListHeight] = useState(maxVisibleOptions * rowHeight);
|
||||
|
||||
const getModelSelectOptionProps = (models: MlModel[]): ModelSelectOptionProps[] =>
|
||||
(models ?? []).map((model) => ({
|
||||
|
@ -39,44 +305,58 @@ export const ModelSelect: React.FC = () => {
|
|||
}));
|
||||
|
||||
const onChange = (options: ModelSelectOptionProps[]) => {
|
||||
const selectedOption = options.find((option) => option.checked === 'on');
|
||||
const selectedModelOption = options.find((option) => option.checked === 'on');
|
||||
|
||||
setInferencePipelineConfiguration({
|
||||
...configuration,
|
||||
inferenceConfig: undefined,
|
||||
modelID: selectedOption?.modelId ?? '',
|
||||
isModelPlaceholderSelected: selectedOption?.isPlaceholder ?? false,
|
||||
modelID: selectedModelOption?.modelId ?? '',
|
||||
isModelPlaceholderSelected: selectedModelOption?.isPlaceholder ?? false,
|
||||
fieldMappings: undefined,
|
||||
pipelineName: isPipelineNameUserSupplied
|
||||
? pipelineName
|
||||
: indexName + '-' + normalizeModelName(selectedOption?.modelId ?? ''),
|
||||
: indexName + '-' + normalizeModelName(selectedModelOption?.modelId ?? ''),
|
||||
});
|
||||
};
|
||||
|
||||
const onSearchChange = (_: string, matchingOptions: ModelSelectOptionProps[]) => {
|
||||
setListHeight(Math.min(maxVisibleOptions, matchingOptions.length) * rowHeight);
|
||||
};
|
||||
|
||||
const renderOption = (option: ModelSelectOptionProps) => <ModelSelectOption {...option} />;
|
||||
|
||||
return (
|
||||
<EuiSelectable
|
||||
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureInferencePipeline-selectTrainedModel`}
|
||||
options={getModelSelectOptionProps(selectableModels)}
|
||||
singleSelection="always"
|
||||
listProps={{
|
||||
bordered: true,
|
||||
rowHeight: useIsWithinMaxBreakpoint('s') ? 180 : 90,
|
||||
showIcons: false,
|
||||
onFocusBadge: false,
|
||||
}}
|
||||
height={360}
|
||||
onChange={onChange}
|
||||
renderOption={renderOption}
|
||||
isLoading={isLoading}
|
||||
searchable
|
||||
>
|
||||
{(list, search) => (
|
||||
<>
|
||||
{search}
|
||||
{list}
|
||||
</>
|
||||
)}
|
||||
</EuiSelectable>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiSelectable
|
||||
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureInferencePipeline-selectTrainedModel`}
|
||||
options={getModelSelectOptionProps(selectableModels)}
|
||||
singleSelection="always"
|
||||
listProps={{
|
||||
bordered: true,
|
||||
rowHeight,
|
||||
onFocusBadge: false,
|
||||
}}
|
||||
height={listHeight}
|
||||
onChange={onChange}
|
||||
renderOption={renderOption}
|
||||
isLoading={isLoading}
|
||||
searchable
|
||||
searchProps={{
|
||||
onChange: onSearchChange,
|
||||
}}
|
||||
>
|
||||
{(list, search) => (
|
||||
<>
|
||||
{search}
|
||||
{list}
|
||||
</>
|
||||
)}
|
||||
</EuiSelectable>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{selectedModel ? <SelectedModel {...selectedModel} /> : <NoModelSelected />}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -23,6 +23,13 @@ import {
|
|||
StartModelApiLogic,
|
||||
StartModelApiLogicActions,
|
||||
} from '../../../../api/ml_models/start_model_api_logic';
|
||||
import { IndexViewLogic } from '../../index_view_logic';
|
||||
|
||||
import {
|
||||
MLInferenceLogic,
|
||||
MLInferenceProcessorsActions,
|
||||
MLInferenceProcessorsValues,
|
||||
} from './ml_inference_logic';
|
||||
|
||||
export interface ModelSelectActions {
|
||||
createModel: (modelId: string) => { modelId: string };
|
||||
|
@ -40,18 +47,26 @@ export interface ModelSelectActions {
|
|||
startModelError: CreateModelApiLogicActions['apiError'];
|
||||
startModelMakeRequest: StartModelApiLogicActions['makeRequest'];
|
||||
startModelSuccess: StartModelApiLogicActions['apiSuccess'];
|
||||
|
||||
setInferencePipelineConfiguration: MLInferenceProcessorsActions['setInferencePipelineConfiguration'];
|
||||
setInferencePipelineConfigurationFromMLInferenceLogic: MLInferenceProcessorsActions['setInferencePipelineConfiguration'];
|
||||
}
|
||||
|
||||
export interface ModelSelectValues {
|
||||
addInferencePipelineModal: MLInferenceProcessorsValues['addInferencePipelineModal'];
|
||||
addInferencePipelineModalFromMLInferenceLogic: MLInferenceProcessorsValues['addInferencePipelineModal'];
|
||||
areActionButtonsDisabled: boolean;
|
||||
createModelError: HttpError | undefined;
|
||||
createModelStatus: Status;
|
||||
ingestionMethod: string;
|
||||
ingestionMethodFromIndexViewLogic: string;
|
||||
isLoading: boolean;
|
||||
isInitialLoading: boolean;
|
||||
modelStateChangeError: string | undefined;
|
||||
modelsData: FetchModelsApiResponse | undefined;
|
||||
modelsStatus: Status;
|
||||
selectableModels: MlModel[];
|
||||
selectedModel: MlModel | undefined;
|
||||
startModelError: HttpError | undefined;
|
||||
startModelStatus: Status;
|
||||
}
|
||||
|
@ -60,16 +75,11 @@ export const ModelSelectLogic = kea<MakeLogicType<ModelSelectValues, ModelSelect
|
|||
actions: {
|
||||
createModel: (modelId: string) => ({ modelId }),
|
||||
fetchModels: true,
|
||||
setInferencePipelineConfiguration: (configuration) => ({ configuration }),
|
||||
startModel: (modelId: string) => ({ modelId }),
|
||||
},
|
||||
connect: {
|
||||
actions: [
|
||||
CreateModelApiLogic,
|
||||
[
|
||||
'makeRequest as createModelMakeRequest',
|
||||
'apiSuccess as createModelSuccess',
|
||||
'apiError as createModelError',
|
||||
],
|
||||
CachedFetchModelsApiLogic,
|
||||
[
|
||||
'makeRequest as fetchModelsMakeRequest',
|
||||
|
@ -77,6 +87,16 @@ export const ModelSelectLogic = kea<MakeLogicType<ModelSelectValues, ModelSelect
|
|||
'apiError as fetchModelsError',
|
||||
'startPolling as startPollingModels',
|
||||
],
|
||||
CreateModelApiLogic,
|
||||
[
|
||||
'makeRequest as createModelMakeRequest',
|
||||
'apiSuccess as createModelSuccess',
|
||||
'apiError as createModelError',
|
||||
],
|
||||
MLInferenceLogic,
|
||||
[
|
||||
'setInferencePipelineConfiguration as setInferencePipelineConfigurationFromMLInferenceLogic',
|
||||
],
|
||||
StartModelApiLogic,
|
||||
[
|
||||
'makeRequest as startModelMakeRequest',
|
||||
|
@ -85,10 +105,14 @@ export const ModelSelectLogic = kea<MakeLogicType<ModelSelectValues, ModelSelect
|
|||
],
|
||||
],
|
||||
values: [
|
||||
CreateModelApiLogic,
|
||||
['status as createModelStatus', 'error as createModelError'],
|
||||
CachedFetchModelsApiLogic,
|
||||
['modelsData', 'status as modelsStatus', 'isInitialLoading'],
|
||||
CreateModelApiLogic,
|
||||
['status as createModelStatus', 'error as createModelError'],
|
||||
IndexViewLogic,
|
||||
['ingestionMethod as ingestionMethodFromIndexViewLogic'],
|
||||
MLInferenceLogic,
|
||||
['addInferencePipelineModal as addInferencePipelineModalFromMLInferenceLogic'],
|
||||
StartModelApiLogic,
|
||||
['status as startModelStatus', 'error as startModelError'],
|
||||
],
|
||||
|
@ -111,17 +135,28 @@ export const ModelSelectLogic = kea<MakeLogicType<ModelSelectValues, ModelSelect
|
|||
startModel: ({ modelId }) => {
|
||||
actions.startModelMakeRequest({ modelId });
|
||||
},
|
||||
setInferencePipelineConfiguration: ({ configuration }) => {
|
||||
actions.setInferencePipelineConfigurationFromMLInferenceLogic(configuration);
|
||||
},
|
||||
startModelSuccess: () => {
|
||||
actions.startPollingModels();
|
||||
},
|
||||
}),
|
||||
path: ['enterprise_search', 'content', 'model_select_logic'],
|
||||
selectors: ({ selectors }) => ({
|
||||
addInferencePipelineModal: [
|
||||
() => [selectors.addInferencePipelineModalFromMLInferenceLogic],
|
||||
(modal) => modal, // Pass-through
|
||||
],
|
||||
areActionButtonsDisabled: [
|
||||
() => [selectors.createModelStatus, selectors.startModelStatus],
|
||||
(createModelStatus: Status, startModelStatus: Status) =>
|
||||
createModelStatus === Status.LOADING || startModelStatus === Status.LOADING,
|
||||
],
|
||||
ingestionMethod: [
|
||||
() => [selectors.ingestionMethodFromIndexViewLogic],
|
||||
(ingestionMethod) => ingestionMethod, // Pass-through
|
||||
],
|
||||
modelStateChangeError: [
|
||||
() => [selectors.createModelError, selectors.startModelError],
|
||||
(createModelError?: HttpError, startModelError?: HttpError) => {
|
||||
|
@ -134,6 +169,13 @@ export const ModelSelectLogic = kea<MakeLogicType<ModelSelectValues, ModelSelect
|
|||
() => [selectors.modelsData],
|
||||
(response: FetchModelsApiResponse) => response ?? [],
|
||||
],
|
||||
selectedModel: [
|
||||
() => [selectors.selectableModels, selectors.addInferencePipelineModal],
|
||||
(
|
||||
models: MlModel[],
|
||||
addInferencePipelineModal: MLInferenceProcessorsValues['addInferencePipelineModal']
|
||||
) => models.find((m) => m.modelId === addInferencePipelineModal.configuration.modelID),
|
||||
],
|
||||
isLoading: [() => [selectors.isInitialLoading], (isInitialLoading) => isInitialLoading],
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -11,19 +11,13 @@ import React from 'react';
|
|||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiLink, EuiText } from '@elastic/eui';
|
||||
import { EuiText } from '@elastic/eui';
|
||||
|
||||
import { MlModelDeploymentState } from '../../../../../../../common/types/ml';
|
||||
import { TrainedModelHealth } from '../ml_model_health';
|
||||
|
||||
import {
|
||||
DeployModelButton,
|
||||
getContextMenuPanel,
|
||||
LicenseBadge,
|
||||
ModelSelectOption,
|
||||
ModelSelectOptionProps,
|
||||
StartModelButton,
|
||||
} from './model_select_option';
|
||||
import { LicenseBadge } from './license_badge';
|
||||
import { ModelSelectOption, ModelSelectOptionProps } from './model_select_option';
|
||||
|
||||
const DEFAULT_PROPS: ModelSelectOptionProps = {
|
||||
modelId: 'model_1',
|
||||
|
@ -73,49 +67,8 @@ describe('ModelSelectOption', () => {
|
|||
const wrapper = shallow(<ModelSelectOption {...props} />);
|
||||
expect(wrapper.find(EuiText)).toHaveLength(0);
|
||||
});
|
||||
it('renders deploy button for a model placeholder', () => {
|
||||
const props = {
|
||||
...DEFAULT_PROPS,
|
||||
isPlaceholder: true,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<ModelSelectOption {...props} />);
|
||||
expect(wrapper.find(DeployModelButton)).toHaveLength(1);
|
||||
});
|
||||
it('renders start button for a downloaded model', () => {
|
||||
const props = {
|
||||
...DEFAULT_PROPS,
|
||||
deploymentState: MlModelDeploymentState.Downloaded,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<ModelSelectOption {...props} />);
|
||||
expect(wrapper.find(StartModelButton)).toHaveLength(1);
|
||||
});
|
||||
it('renders status badge if there is no action button', () => {
|
||||
const wrapper = shallow(<ModelSelectOption {...DEFAULT_PROPS} />);
|
||||
expect(wrapper.find(TrainedModelHealth)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LicenseBadge', () => {
|
||||
it('renders with link if URL is present', () => {
|
||||
const wrapper = shallow(
|
||||
<LicenseBadge
|
||||
licenseType={DEFAULT_PROPS.licenseType!}
|
||||
modelDetailsPageUrl={DEFAULT_PROPS.modelDetailsPageUrl}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(EuiLink)).toHaveLength(1);
|
||||
});
|
||||
it('renders without link if URL is not present', () => {
|
||||
const wrapper = shallow(<LicenseBadge licenseType={DEFAULT_PROPS.licenseType!} />);
|
||||
expect(wrapper.find(EuiLink)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContextMenuPanel', () => {
|
||||
it('gets model details link if URL is present', () => {
|
||||
const panels = getContextMenuPanel('https://model.ai');
|
||||
expect(panels[0].items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,235 +5,68 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useActions, useValues } from 'kea';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiButtonIcon,
|
||||
EuiContextMenu,
|
||||
EuiContextMenuPanelDescriptor,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLink,
|
||||
EuiPopover,
|
||||
EuiRadio,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
EuiTextTruncate,
|
||||
EuiTitle,
|
||||
useIsWithinMaxBreakpoint,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { MlModel, MlModelDeploymentState } from '../../../../../../../common/types/ml';
|
||||
import { KibanaLogic } from '../../../../../shared/kibana';
|
||||
import { MlModel } from '../../../../../../../common/types/ml';
|
||||
import { TrainedModelHealth } from '../ml_model_health';
|
||||
|
||||
import { ModelSelectLogic } from './model_select_logic';
|
||||
import { TRAINED_MODELS_PATH } from './utils';
|
||||
|
||||
export const getContextMenuPanel = (
|
||||
modelDetailsPageUrl?: string
|
||||
): EuiContextMenuPanelDescriptor[] => {
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
items: [
|
||||
{
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelectOption.actionMenu.tuneModelPerformance.label',
|
||||
{
|
||||
defaultMessage: 'Tune model performance',
|
||||
}
|
||||
),
|
||||
icon: 'controlsHorizontal',
|
||||
onClick: () =>
|
||||
KibanaLogic.values.navigateToUrl(TRAINED_MODELS_PATH, {
|
||||
shouldNotCreateHref: true,
|
||||
}),
|
||||
},
|
||||
...(modelDetailsPageUrl
|
||||
? [
|
||||
{
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelectOption.actionMenu.modelDetails.label',
|
||||
{
|
||||
defaultMessage: 'Model details',
|
||||
}
|
||||
),
|
||||
icon: 'popout',
|
||||
href: modelDetailsPageUrl,
|
||||
target: '_blank',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
import { LicenseBadge } from './license_badge';
|
||||
|
||||
export type ModelSelectOptionProps = MlModel & {
|
||||
label: string;
|
||||
checked?: 'on';
|
||||
};
|
||||
|
||||
export const DeployModelButton: React.FC<{ onClick: () => void; disabled: boolean }> = ({
|
||||
onClick,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<EuiButtonEmpty onClick={onClick} disabled={disabled} iconType="download" size="s">
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelectOption.deployButton.label',
|
||||
{
|
||||
defaultMessage: 'Deploy',
|
||||
}
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
};
|
||||
|
||||
export const StartModelButton: React.FC<{ onClick: () => void; disabled: boolean }> = ({
|
||||
onClick,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<EuiButton onClick={onClick} disabled={disabled} color="success" iconType="play" size="s">
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelectOption.startButton.label',
|
||||
{
|
||||
defaultMessage: 'Start',
|
||||
}
|
||||
)}
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelMenuPopover: React.FC<{
|
||||
onClick: () => void;
|
||||
closePopover: () => void;
|
||||
isOpen: boolean;
|
||||
modelDetailsPageUrl?: string;
|
||||
}> = ({ onClick, closePopover, isOpen, modelDetailsPageUrl }) => {
|
||||
return (
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelectOption.actionsButton.label',
|
||||
{
|
||||
defaultMessage: 'All actions',
|
||||
}
|
||||
)}
|
||||
onClick={onClick}
|
||||
iconType="boxesHorizontal"
|
||||
/>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
closePopover={closePopover}
|
||||
anchorPosition="leftCenter"
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenu panels={getContextMenuPanel(modelDetailsPageUrl)} initialPanelId={0} />
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
||||
export interface LicenseBadgeProps {
|
||||
licenseType: string;
|
||||
modelDetailsPageUrl?: string;
|
||||
}
|
||||
|
||||
export const LicenseBadge: React.FC<LicenseBadgeProps> = ({ licenseType, modelDetailsPageUrl }) => {
|
||||
const licenseLabel = i18n.translate(
|
||||
'xpack.enterpriseSearch.content.indices.pipelines.modelSelectOption.licenseBadge.label',
|
||||
{
|
||||
defaultMessage: 'License: {licenseType}',
|
||||
values: {
|
||||
licenseType,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiBadge color="hollow">
|
||||
{modelDetailsPageUrl ? (
|
||||
<EuiLink target="_blank" href={modelDetailsPageUrl}>
|
||||
{licenseLabel}
|
||||
</EuiLink>
|
||||
) : (
|
||||
<p>{licenseLabel}</p>
|
||||
)}
|
||||
</EuiBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelSelectOption: React.FC<ModelSelectOptionProps> = ({
|
||||
modelId,
|
||||
title,
|
||||
description,
|
||||
isPlaceholder,
|
||||
licenseType,
|
||||
modelDetailsPageUrl,
|
||||
deploymentState,
|
||||
deploymentStateReason,
|
||||
isPlaceholder,
|
||||
checked,
|
||||
}) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const onMenuButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen);
|
||||
const closePopover = () => setIsPopoverOpen(false);
|
||||
|
||||
const { createModel, startModel } = useActions(ModelSelectLogic);
|
||||
const { areActionButtonsDisabled } = useValues(ModelSelectLogic);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup alignItems="center" gutterSize={useIsWithinMaxBreakpoint('s') ? 'xs' : 'l'}>
|
||||
{/* Selection radio button */}
|
||||
<EuiFlexItem grow={false} style={{ flexShrink: 0 }}>
|
||||
<EuiRadio
|
||||
title={title}
|
||||
id={modelId}
|
||||
checked={checked === 'on'}
|
||||
onChange={() => null}
|
||||
// @ts-ignore
|
||||
inert
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{/* Title, model ID, description, license */}
|
||||
<EuiFlexItem style={{ overflow: 'hidden' }}>
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xs">
|
||||
<h4>{title}</h4>
|
||||
<h4>
|
||||
<EuiTextTruncate text={title} />
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiTextColor color="subdued">{modelId}</EuiTextColor>
|
||||
<EuiTextColor color="subdued">
|
||||
<EuiTextTruncate text={modelId} />
|
||||
</EuiTextColor>
|
||||
</EuiFlexItem>
|
||||
{(licenseType || description) && (
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup gutterSize="xs" alignItems="center">
|
||||
{licenseType && (
|
||||
<EuiFlexItem grow={false}>
|
||||
{/* Wrap in a div to prevent the badge from growing to a whole row on mobile */}
|
||||
<div>
|
||||
<LicenseBadge
|
||||
licenseType={licenseType}
|
||||
modelDetailsPageUrl={modelDetailsPageUrl}
|
||||
/>
|
||||
</div>
|
||||
{/* Wrap in a span to prevent the badge from growing to a whole row on mobile */}
|
||||
<span>
|
||||
<LicenseBadge licenseType={licenseType} />
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{description && (
|
||||
<EuiFlexItem style={{ overflow: 'hidden' }}>
|
||||
<EuiText size="xs">
|
||||
<div className="eui-textTruncate" title={description}>
|
||||
{description}
|
||||
</div>
|
||||
<EuiTextTruncate text={description} />
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
|
@ -242,36 +75,15 @@ export const ModelSelectOption: React.FC<ModelSelectOptionProps> = ({
|
|||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{/* Status indicator OR action button */}
|
||||
<EuiFlexItem grow={false} style={{ flexShrink: 0 }}>
|
||||
{/* Wrap in a div to prevent the badge/button from growing to a whole row on mobile */}
|
||||
<div>
|
||||
{isPlaceholder ? (
|
||||
<DeployModelButton
|
||||
onClick={() => createModel(modelId)}
|
||||
disabled={areActionButtonsDisabled}
|
||||
/>
|
||||
) : deploymentState === MlModelDeploymentState.Downloaded ? (
|
||||
<StartModelButton
|
||||
onClick={() => startModel(modelId)}
|
||||
disabled={areActionButtonsDisabled}
|
||||
/>
|
||||
) : (
|
||||
<TrainedModelHealth
|
||||
modelState={deploymentState}
|
||||
modelStateReason={deploymentStateReason}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
{/* Actions menu */}
|
||||
<EuiFlexItem grow={false} style={{ flexShrink: 0 }}>
|
||||
<ModelMenuPopover
|
||||
onClick={onMenuButtonClick}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
modelDetailsPageUrl={modelDetailsPageUrl}
|
||||
/>
|
||||
{/* Wrap in a span to prevent the badge from growing to a whole row on mobile */}
|
||||
<span>
|
||||
<TrainedModelHealth
|
||||
modelState={deploymentState}
|
||||
modelStateReason={deploymentStateReason}
|
||||
isDownloadable={isPlaceholder}
|
||||
/>
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
|
|
@ -34,13 +34,13 @@ describe('TrainedModelHealth', () => {
|
|||
it('renders model downloading', () => {
|
||||
const wrapper = shallow(<TrainedModelHealth modelState={MlModelDeploymentState.Downloading} />);
|
||||
const health = wrapper.find(EuiHealth);
|
||||
expect(health.prop('children')).toEqual('Downloading');
|
||||
expect(health.prop('children')).toEqual('Deploying');
|
||||
expect(health.prop('color')).toEqual('warning');
|
||||
});
|
||||
it('renders model downloaded', () => {
|
||||
const wrapper = shallow(<TrainedModelHealth modelState={MlModelDeploymentState.Downloaded} />);
|
||||
const health = wrapper.find(EuiHealth);
|
||||
expect(health.prop('children')).toEqual('Downloaded');
|
||||
expect(health.prop('children')).toEqual('Deployed');
|
||||
expect(health.prop('color')).toEqual('subdued');
|
||||
});
|
||||
it('renders model started', () => {
|
||||
|
@ -68,6 +68,14 @@ describe('TrainedModelHealth', () => {
|
|||
expect(health.prop('children')).toEqual('Not started');
|
||||
expect(health.prop('color')).toEqual('danger');
|
||||
});
|
||||
it('renders model not downloaded for downloadable models', () => {
|
||||
const wrapper = shallow(
|
||||
<TrainedModelHealth modelState={MlModelDeploymentState.NotDeployed} isDownloadable />
|
||||
);
|
||||
const health = wrapper.find(EuiHealth);
|
||||
expect(health.prop('children')).toEqual('Not deployed');
|
||||
expect(health.prop('color')).toEqual('subdued');
|
||||
});
|
||||
it('renders model stopping', () => {
|
||||
const pipeline: InferencePipeline = {
|
||||
...commonModelData,
|
||||
|
|
|
@ -15,28 +15,40 @@ import { FormattedMessage } from '@kbn/i18n-react';
|
|||
import { MlModelDeploymentState } from '../../../../../../common/types/ml';
|
||||
import { TrainedModelState } from '../../../../../../common/types/pipelines';
|
||||
|
||||
const modelNotDownloadedText = i18n.translate(
|
||||
'xpack.enterpriseSearch.inferencePipelineCard.modelState.notDownloaded',
|
||||
{
|
||||
defaultMessage: 'Not deployed',
|
||||
}
|
||||
);
|
||||
const modelNotDownloadedTooltip = i18n.translate(
|
||||
'xpack.enterpriseSearch.inferencePipelineCard.modelState.notDownloaded.tooltip',
|
||||
{
|
||||
defaultMessage: 'This trained model can be deployed',
|
||||
}
|
||||
);
|
||||
const modelDownloadingText = i18n.translate(
|
||||
'xpack.enterpriseSearch.inferencePipelineCard.modelState.downloading',
|
||||
{
|
||||
defaultMessage: 'Downloading',
|
||||
defaultMessage: 'Deploying',
|
||||
}
|
||||
);
|
||||
const modelDownloadingTooltip = i18n.translate(
|
||||
'xpack.enterpriseSearch.inferencePipelineCard.modelState.downloading.tooltip',
|
||||
{
|
||||
defaultMessage: 'This trained model is downloading',
|
||||
defaultMessage: 'This trained model is deploying',
|
||||
}
|
||||
);
|
||||
const modelDownloadedText = i18n.translate(
|
||||
'xpack.enterpriseSearch.inferencePipelineCard.modelState.downloaded',
|
||||
{
|
||||
defaultMessage: 'Downloaded',
|
||||
defaultMessage: 'Deployed',
|
||||
}
|
||||
);
|
||||
const modelDownloadedTooltip = i18n.translate(
|
||||
'xpack.enterpriseSearch.inferencePipelineCard.modelState.downloaded.tooltip',
|
||||
{
|
||||
defaultMessage: 'This trained model is downloaded and can be started',
|
||||
defaultMessage: 'This trained model is deployed and can be started',
|
||||
}
|
||||
);
|
||||
const modelStartedText = i18n.translate(
|
||||
|
@ -100,11 +112,13 @@ const modelNotDeployedTooltip = i18n.translate(
|
|||
export interface TrainedModelHealthProps {
|
||||
modelState: TrainedModelState | MlModelDeploymentState;
|
||||
modelStateReason?: string;
|
||||
isDownloadable?: boolean;
|
||||
}
|
||||
|
||||
export const TrainedModelHealth: React.FC<TrainedModelHealthProps> = ({
|
||||
modelState,
|
||||
modelStateReason,
|
||||
isDownloadable,
|
||||
}) => {
|
||||
let modelHealth: {
|
||||
healthColor: string;
|
||||
|
@ -115,9 +129,9 @@ export const TrainedModelHealth: React.FC<TrainedModelHealthProps> = ({
|
|||
case TrainedModelState.NotDeployed:
|
||||
case MlModelDeploymentState.NotDeployed:
|
||||
modelHealth = {
|
||||
healthColor: 'danger',
|
||||
healthText: modelNotDeployedText,
|
||||
tooltipText: modelNotDeployedTooltip,
|
||||
healthColor: isDownloadable ? 'subdued' : 'danger',
|
||||
healthText: isDownloadable ? modelNotDownloadedText : modelNotDeployedText,
|
||||
tooltipText: isDownloadable ? modelNotDownloadedTooltip : modelNotDeployedTooltip,
|
||||
};
|
||||
break;
|
||||
case MlModelDeploymentState.Downloading:
|
||||
|
|
|
@ -371,7 +371,7 @@ describe('fetchMlModels', () => {
|
|||
expect(models.length).toBe(2);
|
||||
expect(models[0]).toMatchObject({
|
||||
modelId: ELSER_MODEL_ID,
|
||||
deploymentState: MlModelDeploymentState.Downloaded,
|
||||
deploymentState: MlModelDeploymentState.NotDeployed,
|
||||
});
|
||||
expect(mockTrainedModelsProvider.getTrainedModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
|
|
@ -154,8 +154,10 @@ const enrichModelWithDownloadStatus = async (
|
|||
});
|
||||
|
||||
if (modelConfigWithDefinitionStatus && modelConfigWithDefinitionStatus.count > 0) {
|
||||
// We're using NotDeployed for downloaded models. Downloaded is also a valid status, but we want to have the same
|
||||
// status badge as for 3rd party models.
|
||||
model.deploymentState = modelConfigWithDefinitionStatus.trained_model_configs[0].fully_defined
|
||||
? MlModelDeploymentState.Downloaded
|
||||
? MlModelDeploymentState.NotDeployed
|
||||
: MlModelDeploymentState.Downloading;
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue