[8.12] [Enterprise Search] Split details panel from model selection list (#173434) (#173697)

# 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:
Kibana Machine 2023-12-19 18:47:49 -05:00 committed by GitHub
parent 0f9c1ea303
commit f7e2d6e2d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 615 additions and 346 deletions

View file

@ -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>
</>
),

View file

@ -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);
});
});

View file

@ -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>
);
};

View file

@ -119,7 +119,7 @@ export interface MLInferencePipelineOption {
indexFields: string[];
}
interface MLInferenceProcessorsActions {
export interface MLInferenceProcessorsActions {
addSelectedFieldsToMapping: (isTextExpansionModelSelected: boolean) => {
isTextExpansionModelSelected: boolean;
};

View file

@ -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);
});
});
});

View file

@ -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>
);
};

View file

@ -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],
}),
});

View file

@ -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);
});
});

View file

@ -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>
);

View file

@ -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,

View file

@ -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:

View file

@ -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);
});

View file

@ -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;
}
};