diff --git a/x-pack/plugins/ml/common/types/trained_models.ts b/x-pack/plugins/ml/common/types/trained_models.ts index 3f493b01e8066..04c6de67baf31 100644 --- a/x-pack/plugins/ml/common/types/trained_models.ts +++ b/x-pack/plugins/ml/common/types/trained_models.ts @@ -176,6 +176,7 @@ export interface TrainedModelDeploymentStatsResponse { threads_per_allocation: number; number_of_allocations: number; }>; + reason?: string; } export interface AllocatedModel { diff --git a/x-pack/plugins/ml/public/application/model_management/get_model_state.test.tsx b/x-pack/plugins/ml/public/application/model_management/get_model_state.test.tsx new file mode 100644 index 0000000000000..1431b2da0439c --- /dev/null +++ b/x-pack/plugins/ml/public/application/model_management/get_model_state.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 { getModelDeploymentState } from './get_model_state'; +import { MODEL_STATE } from '@kbn/ml-trained-models-utils'; +import type { ModelItem } from './models_list'; + +describe('getModelDeploymentState', () => { + it('returns STARTED if any deployment is in STARTED state', () => { + const model = { + stats: { + model_id: '.elser_model_2', + model_size_stats: { + model_size_bytes: 438123914, + required_native_memory_bytes: 2101346304, + }, + + deployment_stats: [ + { + deployment_id: '.elser_model_2_01', + model_id: '.elser_model_2', + state: 'starting', + }, + { + deployment_id: '.elser_model_2', + model_id: '.elser_model_2', + state: 'started', + allocation_status: { + allocation_count: 1, + target_allocation_count: 1, + state: 'fully_allocated', + }, + }, + ], + }, + } as unknown as ModelItem; + const result = getModelDeploymentState(model); + expect(result).toEqual(MODEL_STATE.STARTED); + }); + + it('returns MODEL_STATE.STARTING if any deployment is in STARTING state', () => { + const model = { + stats: { + model_id: '.elser_model_2', + model_size_stats: { + model_size_bytes: 438123914, + required_native_memory_bytes: 2101346304, + }, + + deployment_stats: [ + { + deployment_id: '.elser_model_2', + model_id: '.elser_model_2', + state: 'stopping', + }, + { + deployment_id: '.elser_model_2_01', + model_id: '.elser_model_2', + state: 'starting', + }, + { + deployment_id: '.elser_model_2', + model_id: '.elser_model_2', + state: 'stopping', + }, + ], + }, + } as unknown as ModelItem; + const result = getModelDeploymentState(model); + expect(result).toEqual(MODEL_STATE.STARTING); + }); + + it('returns MODEL_STATE.STOPPING if every deployment is in STOPPING state', () => { + const model = { + stats: { + model_id: '.elser_model_2', + model_size_stats: { + model_size_bytes: 438123914, + required_native_memory_bytes: 2101346304, + }, + + deployment_stats: [ + { + deployment_id: '.elser_model_2', + model_id: '.elser_model_2', + state: 'stopping', + }, + { + deployment_id: '.elser_model_2_01', + model_id: '.elser_model_2', + state: 'stopping', + }, + ], + }, + } as unknown as ModelItem; + const result = getModelDeploymentState(model); + expect(result).toEqual(MODEL_STATE.STOPPING); + }); + + it('returns undefined for empty deployment stats', () => { + const model = { + stats: { + model_id: '.elser_model_2', + model_size_stats: { + model_size_bytes: 438123914, + required_native_memory_bytes: 2101346304, + }, + + deployment_stats: [], + }, + } as unknown as ModelItem; + const result = getModelDeploymentState(model); + expect(result).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/ml/public/application/model_management/get_model_state_color.tsx b/x-pack/plugins/ml/public/application/model_management/get_model_state.tsx similarity index 63% rename from x-pack/plugins/ml/public/application/model_management/get_model_state_color.tsx rename to x-pack/plugins/ml/public/application/model_management/get_model_state.tsx index d6051847de4ae..8591a3b9e8dc9 100644 --- a/x-pack/plugins/ml/public/application/model_management/get_model_state_color.tsx +++ b/x-pack/plugins/ml/public/application/model_management/get_model_state.tsx @@ -5,13 +5,34 @@ * 2.0. */ -import type { ModelState } from '@kbn/ml-trained-models-utils'; -import { MODEL_STATE } from '@kbn/ml-trained-models-utils'; +import { DEPLOYMENT_STATE, MODEL_STATE, type ModelState } from '@kbn/ml-trained-models-utils'; import type { EuiHealthProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { ModelItem } from './models_list'; + +/** + * Resolves result model state based on the state of each deployment. + * + * If at least one deployment is in the STARTED state, the model state is STARTED. + * Then if none of the deployments are in the STARTED state, but at least one is in the STARTING state, the model state is STARTING. + * If all deployments are in the STOPPING state, the model state is STOPPING. + */ +export const getModelDeploymentState = (model: ModelItem): ModelState | undefined => { + if (!model.stats?.deployment_stats?.length) return; + + if (model.stats?.deployment_stats?.some((v) => v.state === DEPLOYMENT_STATE.STARTED)) { + return MODEL_STATE.STARTED; + } + if (model.stats?.deployment_stats?.some((v) => v.state === DEPLOYMENT_STATE.STARTING)) { + return MODEL_STATE.STARTING; + } + if (model.stats?.deployment_stats?.every((v) => v.state === DEPLOYMENT_STATE.STOPPING)) { + return MODEL_STATE.STOPPING; + } +}; export const getModelStateColor = ( - state: ModelState + state: ModelState | undefined ): { color: EuiHealthProps['color']; name: string } | null => { switch (state) { case MODEL_STATE.DOWNLOADED: diff --git a/x-pack/plugins/ml/public/application/model_management/models_list.tsx b/x-pack/plugins/ml/public/application/model_management/models_list.tsx index d65db2280a0e8..79db82034b8d4 100644 --- a/x-pack/plugins/ml/public/application/model_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/model_management/models_list.tsx @@ -50,7 +50,7 @@ import { isDefined } from '@kbn/ml-is-defined'; import { useStorage } from '@kbn/ml-local-storage'; import { dynamic } from '@kbn/shared-ux-utility'; import useMountedState from 'react-use/lib/useMountedState'; -import { getModelStateColor } from './get_model_state_color'; +import { getModelStateColor, getModelDeploymentState } from './get_model_state'; import { ML_ELSER_CALLOUT_DISMISSED } from '../../../common/types/storage'; import { TechnicalPreviewBadge } from '../components/technical_preview_badge'; import { useModelActions } from './model_actions'; @@ -88,7 +88,11 @@ export type ModelItem = TrainedModelConfigResponse & { origin_job_exists?: boolean; deployment_ids: string[]; putModelConfig?: object; - state: ModelState; + state: ModelState | undefined; + /** + * Description of the current model state + */ + stateDescription?: string; recommended?: boolean; /** * Model name, e.g. elser @@ -374,14 +378,17 @@ export const ModelsList: FC = ({ ...modelStats[0], deployment_stats: modelStats.map((d) => d.deployment_stats).filter(isDefined), }; + + // Extract deployment ids from deployment stats model.deployment_ids = modelStats .map((v) => v.deployment_stats?.deployment_id) .filter(isDefined); - model.state = model.stats.deployment_stats?.some( - (v) => v.state === DEPLOYMENT_STATE.STARTED - ) - ? DEPLOYMENT_STATE.STARTED - : null; + + model.state = getModelDeploymentState(model); + model.stateDescription = model.stats.deployment_stats.reduce((acc, c) => { + if (acc) return acc; + return c.reason ?? ''; + }, ''); }); const elasticModels = models.filter((model) =>