[ML] Fixes for the Model Management table (#117293) (#117813)

* [ML] allocated models as a table

* [ML] handle fetching stats for stopped model

* [ML] change date formatter, enable check for start and stop action, change icon

* [ML] hide stats tab when not stats are available

* [ML] update state on action

* [ML] update memory breakdown message

* [ML] fix type

* [ML] fix functional test

* [ML] render nodes as links

* [ML] fix locator types

* [ML] support node name in the URL state

* [ML] update icon

* [ML] routing state with reason tooltip

* [ML] move experimental tag

* [ML] add state column, fix import

* [ML] update stats on aciton

* [ML] refresh by timer

* [ML] rename fetchStats callback

* [ML] replace with promise.all

* [ML] add deployment state const

Co-authored-by: Dima Arnautov <dmitrii.arnautov@elastic.co>
This commit is contained in:
Kibana Machine 2021-11-08 05:54:24 -05:00 committed by GitHub
parent e5960af1ad
commit a11ae4949e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 610 additions and 272 deletions

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const DEPLOYMENT_STATE = {
STARTED: 'started',
STARTING: 'starting',
STOPPING: 'stopping',
} as const;
export type DeploymentState = typeof DEPLOYMENT_STATE[keyof typeof DEPLOYMENT_STATE];

View file

@ -188,6 +188,10 @@ export interface TrainedModelsQueryState {
modelId?: string;
}
export interface TrainedModelsNodesQueryState {
nodeId?: string;
}
export type DataFrameAnalyticsUrlState = MLPageState<
| typeof ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE
| typeof ML_PAGES.DATA_FRAME_ANALYTICS_MAP
@ -255,7 +259,8 @@ export type MlLocatorState =
| CalendarEditUrlState
| FilterEditUrlState
| MlGenericUrlState
| TrainedModelsUrlState;
| TrainedModelsUrlState
| TrainedModelsNodesUrlState;
export type MlLocatorParams = MlLocatorState & SerializableRecord;
@ -265,3 +270,8 @@ export type TrainedModelsUrlState = MLPageState<
typeof ML_PAGES.TRAINED_MODELS_MANAGE,
TrainedModelsQueryState | undefined
>;
export type TrainedModelsNodesUrlState = MLPageState<
typeof ML_PAGES.TRAINED_MODELS_NODES,
TrainedModelsNodesQueryState | undefined
>;

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import { DataFrameAnalyticsConfig } from './data_frame_analytics';
import { FeatureImportanceBaseline, TotalFeatureImportance } from './feature_importance';
import { XOR } from './common';
import type { DataFrameAnalyticsConfig } from './data_frame_analytics';
import type { FeatureImportanceBaseline, TotalFeatureImportance } from './feature_importance';
import type { XOR } from './common';
import type { DeploymentState } from '../constants/trained_models';
export interface IngestStats {
count: number;
@ -17,8 +18,8 @@ export interface IngestStats {
}
export interface TrainedModelStat {
model_id: string;
pipeline_count: number;
model_id?: string;
pipeline_count?: number;
inference_stats?: {
failure_count: number;
inference_count: number;
@ -100,6 +101,9 @@ export interface TrainedModelConfigResponse {
tags: string[];
version: string;
inference_config?: Record<string, any>;
/**
* Associated pipelines. Extends response from the ES endpoint.
*/
pipelines?: Record<string, PipelineDefinition> | null;
}
@ -125,7 +129,7 @@ export interface TrainedModelDeploymentStatsResponse {
model_size_bytes: number;
inference_threads: number;
model_threads: number;
state: string;
state: DeploymentState;
allocation_status: { target_allocation_count: number; state: string; allocation_count: number };
nodes: Array<{
node: Record<
@ -150,24 +154,35 @@ export interface TrainedModelDeploymentStatsResponse {
}>;
}
export interface AllocatedModel {
inference_threads: number;
allocation_status: {
target_allocation_count: number;
state: string;
allocation_count: number;
};
model_id: string;
state: string;
model_threads: number;
model_size_bytes: number;
node: {
average_inference_time_ms: number;
inference_count: number;
routing_state: {
routing_state: string;
reason?: string;
};
last_access?: number;
};
}
export interface NodeDeploymentStatsResponse {
id: string;
name: string;
transport_address: string;
attributes: Record<string, string>;
roles: string[];
allocated_models: Array<{
inference_threads: number;
allocation_status: {
target_allocation_count: number;
state: string;
allocation_count: number;
};
model_id: string;
state: string;
model_threads: number;
model_size_bytes: number;
}>;
allocated_models: AllocatedModel[];
memory_overview: {
machine_memory: {
/** Total machine memory in bytes */

View file

@ -7,7 +7,7 @@
import React, { FC, useState, useEffect } from 'react';
import { EuiPageHeader, EuiBetaBadge } from '@elastic/eui';
import { EuiPageHeader } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TabId } from './navigation_menu';
import { useMlKibana, useMlLocator, useNavigateToPath } from '../../contexts/kibana';
@ -57,20 +57,6 @@ function getTabs(disableLinks: boolean): Tab[] {
defaultMessage: 'Model Management',
}),
disabled: disableLinks,
betaTag: (
<EuiBetaBadge
label={i18n.translate('xpack.ml.navMenu.trainedModelsTabBetaLabel', {
defaultMessage: 'Experimental',
})}
size="m"
color="hollow"
iconType="beaker"
tooltipContent={i18n.translate('xpack.ml.navMenu.trainedModelsTabBetaTooltipContent', {
defaultMessage:
"Model Management is an experimental feature and subject to change. We'd love to hear your feedback.",
})}
/>
),
},
{
id: 'datavisualizer',
@ -201,7 +187,6 @@ export const MainTabs: FC<Props> = ({ tabId, disableLinks }) => {
},
'data-test-subj': testSubject + (id === selectedTabId ? ' selected' : ''),
isSelected: id === selectedTabId,
append: tab.betaTag,
};
})}
/>

View file

@ -6,12 +6,26 @@
*/
import { useMlKibana } from './kibana_context';
import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats/common';
export function useFieldFormatter(fieldType: 'bytes') {
/**
* Set of reasonable defaults for formatters for the ML app.
*/
const defaultParam = {
[FIELD_FORMAT_IDS.DURATION]: {
inputFormat: 'milliseconds',
outputFormat: 'humanizePrecise',
},
} as Record<FIELD_FORMAT_IDS, object | undefined>;
export function useFieldFormatter(fieldType: FIELD_FORMAT_IDS) {
const {
services: { fieldFormats },
} = useMlKibana();
const fieldFormatter = fieldFormats.deserialize({ id: fieldType });
const fieldFormatter = fieldFormats.deserialize({
id: fieldType,
params: defaultParam[fieldType],
});
return fieldFormatter.convert.bind(fieldFormatter);
}

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import React, { FC, Fragment } from 'react';
import React, { FC, Fragment, useEffect, useState } from 'react';
import { omit } from 'lodash';
import {
EuiBadge,
EuiButtonEmpty,
@ -15,6 +16,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiListGroup,
EuiNotificationBadge,
EuiPanel,
EuiSpacer,
@ -25,11 +27,13 @@ import {
} from '@elastic/eui';
import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list';
import { FormattedMessage } from '@kbn/i18n/react';
import type { EuiListGroupItemProps } from '@elastic/eui/src/components/list_group/list_group_item';
import { ModelItemFull } from './models_list';
import { useMlKibana } from '../../contexts/kibana';
import { useMlKibana, useMlLocator } from '../../contexts/kibana';
import { timeFormatter } from '../../../../common/util/date_utils';
import { isDefined } from '../../../../common/types/guards';
import { isPopulatedObject } from '../../../../common';
import { ML_PAGES } from '../../../../common/constants/locator';
interface ExpandedRowProps {
item: ModelItemFull;
@ -85,6 +89,12 @@ export function formatToListItems(
}
export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
const mlLocator = useMlLocator();
const [deploymentStatsItems, setDeploymentStats] = useState<EuiDescriptionListProps['listItems']>(
[]
);
const {
inference_config: inferenceConfig,
stats,
@ -119,6 +129,42 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
services: { share },
} = useMlKibana();
useEffect(
function updateDeploymentState() {
(async function () {
const { nodes, ...deploymentStats } = stats.deployment_stats ?? {};
if (!isPopulatedObject(deploymentStats)) return;
const result = formatToListItems(deploymentStats)!;
const items: EuiListGroupItemProps[] = await Promise.all(
nodes!.map(async (v) => {
const nodeObject = Object.values(v.node)[0];
const href = await mlLocator!.getUrl({
page: ML_PAGES.TRAINED_MODELS_NODES,
pageState: {
nodeId: nodeObject.name,
},
});
return {
label: nodeObject.name,
href,
};
})
);
result.push({
title: 'nodes',
description: <EuiListGroup size={'s'} gutterSize={'s'} listItems={items} />,
});
setDeploymentStats(result);
})();
},
[stats.deployment_stats]
);
const tabs = [
{
id: 'details',
@ -234,164 +280,168 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
},
]
: []),
{
id: 'stats',
name: (
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.statsTabLabel"
defaultMessage="Stats"
/>
),
content: (
<>
<EuiSpacer size={'m'} />
{stats.deployment_stats && (
<>
<EuiPanel>
<EuiTitle size={'xs'}>
<h5>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.deploymentStatsTitle"
defaultMessage="Deployment stats"
/>
</h5>
</EuiTitle>
...(isPopulatedObject(omit(stats, 'pipeline_count'))
? [
{
id: 'stats',
name: (
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.statsTabLabel"
defaultMessage="Stats"
/>
),
content: (
<>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(stats.deployment_stats)}
/>
</EuiPanel>
<EuiSpacer size={'m'} />
</>
)}
<EuiFlexGrid columns={2}>
{stats.inference_stats && (
<EuiFlexItem>
<EuiPanel>
<EuiTitle size={'xs'}>
<h5>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.inferenceStatsTitle"
defaultMessage="Inference stats"
/>
</h5>
</EuiTitle>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(stats.inference_stats)}
/>
</EuiPanel>
</EuiFlexItem>
)}
{stats.ingest?.total && (
<EuiFlexItem>
<EuiPanel style={{ maxHeight: '400px', overflow: 'auto' }}>
<EuiTitle size={'xs'}>
<h5>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.ingestStatsTitle"
defaultMessage="Ingest stats"
/>
</h5>
</EuiTitle>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(stats.ingest.total)}
/>
{stats.ingest?.pipelines && (
<>
<EuiSpacer size={'m'} />
{!!deploymentStatsItems?.length ? (
<>
<EuiPanel>
<EuiTitle size={'xs'}>
<h5>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.byPipelineTitle"
defaultMessage="By pipeline"
id="xpack.ml.trainedModels.modelsList.expandedRow.deploymentStatsTitle"
defaultMessage="Deployment stats"
/>
</h5>
</EuiTitle>
<EuiSpacer size={'s'} />
{Object.entries(stats.ingest.pipelines).map(
([pipelineName, { processors, ...pipelineStats }], i) => {
return (
<Fragment key={pipelineName}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle size={'xs'}>
<EuiTextColor color="subdued">
<h5>
{i + 1}. {pipelineName}
</h5>
</EuiTextColor>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule size={'full'} margin={'s'} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(pipelineStats)}
/>
<EuiSpacer size={'m'} />
<EuiTitle size={'xxs'}>
<h6>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.byProcessorTitle"
defaultMessage="By processor"
/>
</h6>
</EuiTitle>
<EuiSpacer size={'s'} />
<>
{processors.map((processor) => {
const name = Object.keys(processor)[0];
const { stats: processorStats } = processor[name];
return (
<Fragment key={name}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle size={'xxs'}>
<EuiTextColor color="subdued">
<h6>{name}</h6>
</EuiTextColor>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule size={'full'} margin={'s'} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(processorStats)}
/>
</Fragment>
);
})}
</>
</Fragment>
);
}
)}
</>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={deploymentStatsItems}
/>
</EuiPanel>
<EuiSpacer size={'m'} />
</>
) : null}
<EuiFlexGrid columns={2}>
{stats.inference_stats && (
<EuiFlexItem>
<EuiPanel>
<EuiTitle size={'xs'}>
<h5>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.inferenceStatsTitle"
defaultMessage="Inference stats"
/>
</h5>
</EuiTitle>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(stats.inference_stats)}
/>
</EuiPanel>
</EuiFlexItem>
)}
</EuiPanel>
</EuiFlexItem>
)}
</EuiFlexGrid>
</>
),
},
{stats.ingest?.total && (
<EuiFlexItem>
<EuiPanel style={{ maxHeight: '400px', overflow: 'auto' }}>
<EuiTitle size={'xs'}>
<h5>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.ingestStatsTitle"
defaultMessage="Ingest stats"
/>
</h5>
</EuiTitle>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(stats.ingest.total)}
/>
{stats.ingest?.pipelines && (
<>
<EuiSpacer size={'m'} />
<EuiTitle size={'xs'}>
<h5>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.byPipelineTitle"
defaultMessage="By pipeline"
/>
</h5>
</EuiTitle>
<EuiSpacer size={'s'} />
{Object.entries(stats.ingest.pipelines).map(
([pipelineName, { processors, ...pipelineStats }], i) => {
return (
<Fragment key={pipelineName}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle size={'xs'}>
<EuiTextColor color="subdued">
<h5>
{i + 1}. {pipelineName}
</h5>
</EuiTextColor>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule size={'full'} margin={'s'} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(pipelineStats)}
/>
<EuiSpacer size={'m'} />
<EuiTitle size={'xxs'}>
<h6>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.byProcessorTitle"
defaultMessage="By processor"
/>
</h6>
</EuiTitle>
<EuiSpacer size={'s'} />
<>
{processors.map((processor) => {
const name = Object.keys(processor)[0];
const { stats: processorStats } = processor[name];
return (
<Fragment key={name}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle size={'xxs'}>
<EuiTextColor color="subdued">
<h6>{name}</h6>
</EuiTextColor>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule size={'full'} margin={'s'} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(processorStats)}
/>
</Fragment>
);
})}
</>
</Fragment>
);
}
)}
</>
)}
</EuiPanel>
</EuiFlexItem>
)}
</EuiFlexGrid>
</>
),
},
]
: []),
...(pipelines && Object.keys(pipelines).length > 0
? [
{

View file

@ -5,19 +5,19 @@
* 2.0.
*/
import React, { FC, useState, useCallback, useMemo } from 'react';
import { groupBy } from 'lodash';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { omit } from 'lodash';
import {
EuiInMemoryTable,
EuiBadge,
EuiButton,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiButton,
EuiSpacer,
EuiButtonIcon,
EuiBadge,
SearchFilterConfig,
EuiInMemoryTable,
EuiSearchBarProps,
EuiSpacer,
EuiTitle,
SearchFilterConfig,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -48,9 +48,12 @@ import { ListingPageUrlState } from '../../../../common/types/common';
import { usePageUrlState } from '../../util/url_state';
import { ExpandedRow } from './expanded_row';
import { isPopulatedObject } from '../../../../common';
import { timeFormatter } from '../../../../common/util/date_utils';
import { useTableSettings } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings';
import { useToastNotificationService } from '../../services/toast_notification_service';
import { useFieldFormatter } from '../../contexts/kibana/use_field_formatter';
import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats/common';
import { useRefresh } from '../../routing/use_refresh';
import { DEPLOYMENT_STATE } from '../../../../common/constants/trained_models';
type Stats = Omit<TrainedModelStat, 'model_id'>;
@ -82,11 +85,15 @@ export const ModelsList: FC = () => {
} = useMlKibana();
const urlLocator = useMlLocator()!;
const dateFormatter = useFieldFormatter(FIELD_FORMAT_IDS.DATE);
const [pageState, updatePageState] = usePageUrlState(
ML_PAGES.TRAINED_MODELS_MANAGE,
getDefaultModelsListState()
);
const refresh = useRefresh();
const searchQueryText = pageState.queryText ?? '';
const canDeleteDataFrameAnalytics = capabilities.ml.canDeleteDataFrameAnalytics as boolean;
@ -121,7 +128,7 @@ export const ModelsList: FC = () => {
size: 1000,
});
const newItems = [];
const newItems: ModelItem[] = [];
const expandedItemsToRefresh = [];
for (const model of response) {
@ -145,6 +152,11 @@ export const ModelsList: FC = () => {
}
}
// Need to fetch state for 3rd party models to enable/disable actions
await fetchAndPopulateDeploymentStats(
newItems.filter((v) => v.model_type.includes('pytorch'))
);
setItems(newItems);
if (expandedItemsToRefresh.length > 0) {
@ -175,6 +187,13 @@ export const ModelsList: FC = () => {
onRefresh: fetchModelsData,
});
useEffect(
function updateOnTimerRefresh() {
fetchModelsData();
},
[refresh]
);
const modelsStats: ModelsBarStats = useMemo(() => {
return {
total: {
@ -191,8 +210,6 @@ export const ModelsList: FC = () => {
* Fetches models stats and update the original object
*/
const fetchModelsStats = useCallback(async (models: ModelItem[]) => {
const { true: pytorchModels } = groupBy(models, (m) => m.model_type === 'pytorch');
try {
if (models) {
const { trained_model_stats: modelsStatsResponse } =
@ -200,19 +217,12 @@ export const ModelsList: FC = () => {
for (const { model_id: id, ...stats } of modelsStatsResponse) {
const model = models.find((m) => m.model_id === id);
model!.stats = stats;
}
}
if (pytorchModels) {
const { deployment_stats: deploymentStatsResponse } =
await trainedModelsApiService.getTrainedModelDeploymentStats(
pytorchModels.map((m) => m.model_id)
);
for (const { model_id: id, ...stats } of deploymentStatsResponse) {
const model = models.find((m) => m.model_id === id);
model!.stats!.deployment_stats = stats;
if (model) {
model.stats = {
...(model.stats ?? {}),
...stats,
};
}
}
}
@ -227,6 +237,39 @@ export const ModelsList: FC = () => {
}
}, []);
/**
* Updates model items with deployment stats;
*
* We have to fetch all deployment stats on each update,
* because for stopped models the API returns 404 response.
*/
const fetchAndPopulateDeploymentStats = useCallback(async (modelItems: ModelItem[]) => {
try {
const { deployment_stats: deploymentStats } =
await trainedModelsApiService.getTrainedModelDeploymentStats('*');
for (const deploymentStat of deploymentStats) {
const deployedModel = modelItems.find(
(model) => model.model_id === deploymentStat.model_id
);
if (deployedModel) {
deployedModel.stats = {
...(deployedModel.stats ?? {}),
deployment_stats: omit(deploymentStat, 'model_id'),
};
}
}
} catch (error) {
displayErrorToast(
error,
i18n.translate('xpack.ml.trainedModels.modelsList.fetchDeploymentStatsErrorMessage', {
defaultMessage: 'Fetch deployment stats failed',
})
);
}
}, []);
/**
* Unique inference types from models
*/
@ -361,12 +404,19 @@ export const ModelsList: FC = () => {
description: i18n.translate('xpack.ml.inference.modelsList.startModelAllocationActionLabel', {
defaultMessage: 'Start allocation',
}),
icon: 'download',
icon: 'play',
type: 'icon',
isPrimary: true,
enabled: (item) => {
const { state } = item.stats?.deployment_stats ?? {};
return (
!isLoading && state !== DEPLOYMENT_STATE.STARTED && state !== DEPLOYMENT_STATE.STARTING
);
},
available: (item) => item.model_type === 'pytorch',
onClick: async (item) => {
try {
setIsLoading(true);
await trainedModelsApiService.startModelAllocation(item.model_id);
displaySuccessToast(
i18n.translate('xpack.ml.trainedModels.modelsList.startSuccess', {
@ -376,6 +426,7 @@ export const ModelsList: FC = () => {
},
})
);
await fetchModelsData();
} catch (e) {
displayErrorToast(
e,
@ -386,6 +437,7 @@ export const ModelsList: FC = () => {
},
})
);
setIsLoading(false);
}
},
},
@ -400,9 +452,14 @@ export const ModelsList: FC = () => {
type: 'icon',
isPrimary: true,
available: (item) => item.model_type === 'pytorch',
enabled: (item) => !isPopulatedObject(item.pipelines),
enabled: (item) =>
!isLoading &&
!isPopulatedObject(item.pipelines) &&
isPopulatedObject(item.stats?.deployment_stats) &&
item.stats?.deployment_stats?.state !== DEPLOYMENT_STATE.STOPPING,
onClick: async (item) => {
try {
setIsLoading(true);
await trainedModelsApiService.stopModelAllocation(item.model_id);
displaySuccessToast(
i18n.translate('xpack.ml.trainedModels.modelsList.stopSuccess', {
@ -412,6 +469,8 @@ export const ModelsList: FC = () => {
},
})
);
// Need to fetch model state updates
await fetchModelsData();
} catch (e) {
displayErrorToast(
e,
@ -422,6 +481,7 @@ export const ModelsList: FC = () => {
},
})
);
setIsLoading(false);
}
},
},
@ -521,13 +581,25 @@ export const ModelsList: FC = () => {
),
'data-test-subj': 'mlModelsTableColumnType',
},
{
name: i18n.translate('xpack.ml.trainedModels.modelsList.stateHeader', {
defaultMessage: 'State',
}),
sortable: (item) => item.stats?.deployment_stats?.state,
align: 'left',
render: (model: ModelItem) => {
const state = model.stats?.deployment_stats?.state;
return state ? <EuiBadge color="hollow">{state}</EuiBadge> : null;
},
'data-test-subj': 'mlModelsTableColumnDeploymentState',
},
{
field: ModelsTableToConfigMapping.createdAt,
name: i18n.translate('xpack.ml.trainedModels.modelsList.createdAtHeader', {
defaultMessage: 'Created at',
}),
dataType: 'date',
render: timeFormatter,
render: (v: number) => dateFormatter(v),
sortable: true,
'data-test-subj': 'mlModelsTableColumnCreatedAt',
},

View file

@ -0,0 +1,131 @@
/*
* 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, { FC } from 'react';
import { EuiBadge, EuiInMemoryTable, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table';
import type {
AllocatedModel,
NodeDeploymentStatsResponse,
} from '../../../../common/types/trained_models';
import { useFieldFormatter } from '../../contexts/kibana/use_field_formatter';
import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats/common';
interface AllocatedModelsProps {
models: NodeDeploymentStatsResponse['allocated_models'];
}
export const AllocatedModels: FC<AllocatedModelsProps> = ({ models }) => {
const bytesFormatter = useFieldFormatter(FIELD_FORMAT_IDS.BYTES);
const dateFormatter = useFieldFormatter(FIELD_FORMAT_IDS.DATE);
const durationFormatter = useFieldFormatter(FIELD_FORMAT_IDS.DURATION);
const columns: Array<EuiBasicTableColumn<AllocatedModel>> = [
{
field: 'model_id',
name: i18n.translate('xpack.ml.trainedModels.nodesList.modelsList.modelNameHeader', {
defaultMessage: 'Name',
}),
width: '300px',
sortable: true,
truncateText: false,
'data-test-subj': 'mlAllocatedModelsTableName',
},
{
name: i18n.translate('xpack.ml.trainedModels.nodesList.modelsList.modelSizeHeader', {
defaultMessage: 'Size',
}),
width: '100px',
truncateText: true,
'data-test-subj': 'mlAllocatedModelsTableSize',
render: (v: AllocatedModel) => {
return bytesFormatter(v.model_size_bytes);
},
},
{
field: 'state',
name: i18n.translate('xpack.ml.trainedModels.nodesList.modelsList.modelStateHeader', {
defaultMessage: 'State',
}),
width: '100px',
truncateText: false,
'data-test-subj': 'mlAllocatedModelsTableState',
},
{
name: i18n.translate(
'xpack.ml.trainedModels.nodesList.modelsList.modelAvgInferenceTimeHeader',
{
defaultMessage: 'Avg inference time',
}
),
width: '100px',
truncateText: false,
'data-test-subj': 'mlAllocatedModelsTableAvgInferenceTime',
render: (v: AllocatedModel) => {
return v.node.average_inference_time_ms
? durationFormatter(v.node.average_inference_time_ms)
: '-';
},
},
{
name: i18n.translate(
'xpack.ml.trainedModels.nodesList.modelsList.modelInferenceCountHeader',
{
defaultMessage: 'Inference count',
}
),
width: '100px',
'data-test-subj': 'mlAllocatedModelsTableInferenceCount',
render: (v: AllocatedModel) => {
return v.node.inference_count;
},
},
{
name: i18n.translate('xpack.ml.trainedModels.nodesList.modelsList.modelLastAccessHeader', {
defaultMessage: 'Last access',
}),
width: '200px',
'data-test-subj': 'mlAllocatedModelsTableInferenceCount',
render: (v: AllocatedModel) => {
return dateFormatter(v.node.last_access);
},
},
{
name: i18n.translate('xpack.ml.trainedModels.nodesList.modelsList.modelRoutingStateHeader', {
defaultMessage: 'Routing state',
}),
width: '100px',
'data-test-subj': 'mlAllocatedModelsTableRoutingState',
render: (v: AllocatedModel) => {
const { routing_state: routingState, reason } = v.node.routing_state;
return (
<EuiToolTip content={reason ? reason : ''}>
<EuiBadge color={reason ? 'danger' : 'hollow'}>{routingState}</EuiBadge>
</EuiToolTip>
);
},
},
];
return (
<EuiInMemoryTable<AllocatedModel>
allowNeutralSort={false}
columns={columns}
hasActions={false}
isExpandable={false}
isSelectable={false}
items={models}
itemId={'model_id'}
rowProps={(item) => ({
'data-test-subj': `mlAllocatedModelTableRow row-${item.model_id}`,
})}
onTableChange={() => {}}
data-test-subj={'mlNodesTable'}
/>
);
};

View file

@ -9,17 +9,15 @@ import React, { FC } from 'react';
import {
EuiDescriptionList,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPanel,
EuiSpacer,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { NodeItemWithStats } from './nodes_list';
import { formatToListItems } from '../models_management/expanded_row';
import { AllocatedModels } from './allocated_models';
interface ExpandedRowProps {
item: NodeItemWithStats;
@ -55,8 +53,6 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
listItems={formatToListItems(details)}
/>
</EuiPanel>
<EuiSpacer size={'m'} />
</EuiFlexItem>
<EuiFlexItem>
@ -76,10 +72,10 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
listItems={formatToListItems(attributes)}
/>
</EuiPanel>
</EuiFlexItem>
<EuiSpacer size={'m'} />
{allocatedModels.length > 0 ? (
{allocatedModels.length > 0 ? (
<EuiFlexItem grow={2}>
<EuiPanel>
<EuiTitle size={'xs'}>
<h5>
@ -91,34 +87,10 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
</EuiTitle>
<EuiSpacer size={'m'} />
{allocatedModels.map(({ model_id: modelId, ...rest }) => {
return (
<>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<EuiTextColor color="subdued">
<h5>{modelId}</h5>
</EuiTextColor>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule size={'full'} margin={'s'} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(rest)}
/>
<EuiSpacer size={'s'} />
</>
);
})}
<AllocatedModels models={allocatedModels} />
</EuiPanel>
) : null}
</EuiFlexItem>
</EuiFlexItem>
) : null}
</EuiFlexGrid>
</>
);

View file

@ -8,25 +8,26 @@
import { i18n } from '@kbn/i18n';
import React, { FC, useMemo } from 'react';
import {
Chart,
Settings,
BarSeries,
ScaleType,
Axis,
BarSeries,
Chart,
Position,
ScaleType,
SeriesColorAccessor,
Settings,
} from '@elastic/charts';
import { euiPaletteGray } from '@elastic/eui';
import { NodeDeploymentStatsResponse } from '../../../../common/types/trained_models';
import { useFieldFormatter } from '../../contexts/kibana/use_field_formatter';
import { useCurrentEuiTheme } from '../../components/color_range_legend';
import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats/common';
interface MemoryPreviewChartProps {
memoryOverview: NodeDeploymentStatsResponse['memory_overview'];
}
export const MemoryPreviewChart: FC<MemoryPreviewChartProps> = ({ memoryOverview }) => {
const bytesFormatter = useFieldFormatter('bytes');
const bytesFormatter = useFieldFormatter(FIELD_FORMAT_IDS.BYTES);
const { euiTheme } = useCurrentEuiTheme();
@ -112,7 +113,7 @@ export const MemoryPreviewChart: FC<MemoryPreviewChartProps> = ({ memoryOverview
tooltip={{
headerFormatter: ({ value }) =>
i18n.translate('xpack.ml.trainedModels.nodesList.memoryBreakdown', {
defaultMessage: 'Approximate memory breakdown based on the node info',
defaultMessage: 'Approximate memory breakdown',
}),
}}
/>

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useCallback, useMemo, useState } from 'react';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
@ -31,6 +31,8 @@ import { MemoryPreviewChart } from './memory_preview_chart';
import { useFieldFormatter } from '../../contexts/kibana/use_field_formatter';
import { ListingPageUrlState } from '../../../../common/types/common';
import { useToastNotificationService } from '../../services/toast_notification_service';
import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats/common';
import { useRefresh } from '../../routing/use_refresh';
export type NodeItem = NodeDeploymentStatsResponse;
@ -47,8 +49,11 @@ export const getDefaultNodesListState = (): ListingPageUrlState => ({
export const NodesList: FC = () => {
const trainedModelsApiService = useTrainedModelsApiService();
const refresh = useRefresh();
const { displayErrorToast } = useToastNotificationService();
const bytesFormatter = useFieldFormatter('bytes');
const bytesFormatter = useFieldFormatter(FIELD_FORMAT_IDS.BYTES);
const [items, setItems] = useState<NodeItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, JSX.Element>>(
@ -179,6 +184,13 @@ export const NodesList: FC = () => {
onRefresh: fetchNodesData,
});
useEffect(
function updateOnTimerRefresh() {
fetchNodesData();
},
[refresh]
);
return (
<>
<EuiSpacer size="m" />

View file

@ -10,6 +10,7 @@ import React, { FC, Fragment, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiBetaBadge,
EuiFlexGroup,
EuiFlexItem,
EuiPage,
@ -21,6 +22,7 @@ import {
} from '@elastic/eui';
import { useLocation } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { NavigationMenu } from '../components/navigation_menu';
import { ModelsList } from './models_management';
import { TrainedModelsNavigationBar } from './navigation_bar';
@ -44,14 +46,35 @@ export const Page: FC = () => {
<EuiPageBody>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle>
<h1>
<FormattedMessage
id="xpack.ml.trainedModels.title"
defaultMessage="Trained Models"
<EuiFlexGroup responsive={false} wrap={false} alignItems={'center'} gutterSize={'m'}>
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>
<FormattedMessage
id="xpack.ml.trainedModels.title"
defaultMessage="Trained Models"
/>
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBetaBadge
label={i18n.translate('xpack.ml.navMenu.trainedModelsTabBetaLabel', {
defaultMessage: 'Experimental',
})}
size="m"
color="hollow"
tooltipContent={i18n.translate(
'xpack.ml.navMenu.trainedModelsTabBetaTooltipContent',
{
defaultMessage:
"Model Management is an experimental feature and subject to change. We'd love to hear your feedback.",
}
)}
tooltipPosition={'right'}
/>
</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageHeaderSection>
<EuiPageHeaderSection>
<EuiFlexGroup alignItems="center" gutterSize="s">

View file

@ -5,8 +5,13 @@
* 2.0.
*/
import { TrainedModelsUrlState } from '../../../common/types/locator';
import type {
TrainedModelsNodesUrlState,
TrainedModelsUrlState,
} from '../../../common/types/locator';
import { ML_PAGES } from '../../../common/constants/locator';
import type { AppPageState, ListingPageUrlState } from '../../../common/types/common';
import { setStateToKbnUrl } from '../../../../../../src/plugins/kibana_utils/public';
export function formatTrainedModelsManagementUrl(
appBasePath: string,
@ -14,3 +19,31 @@ export function formatTrainedModelsManagementUrl(
): string {
return `${appBasePath}/${ML_PAGES.TRAINED_MODELS_MANAGE}`;
}
export function formatTrainedModelsNodesManagementUrl(
appBasePath: string,
mlUrlGeneratorState: TrainedModelsNodesUrlState['pageState']
): string {
let url = `${appBasePath}/${ML_PAGES.TRAINED_MODELS_NODES}`;
if (mlUrlGeneratorState) {
const { nodeId } = mlUrlGeneratorState;
if (nodeId) {
const nodesListState: Partial<ListingPageUrlState> = {
queryText: `name:(${nodeId})`,
};
const queryState: AppPageState<ListingPageUrlState> = {
[ML_PAGES.TRAINED_MODELS_NODES]: nodesListState,
};
url = setStateToKbnUrl<AppPageState<ListingPageUrlState>>(
'_a',
queryState,
{ useHash: false, storeInHashQuery: false },
url
);
}
}
return url;
}

View file

@ -26,7 +26,10 @@ import {
formatEditCalendarUrl,
formatEditFilterUrl,
} from './formatters';
import { formatTrainedModelsManagementUrl } from './formatters/trained_models';
import {
formatTrainedModelsManagementUrl,
formatTrainedModelsNodesManagementUrl,
} from './formatters/trained_models';
export type { MlLocatorParams, MlLocator };
@ -70,6 +73,9 @@ export class MlLocatorDefinition implements LocatorDefinition<MlLocatorParams> {
case ML_PAGES.TRAINED_MODELS_MANAGE:
path = formatTrainedModelsManagementUrl('', params.pageState);
break;
case ML_PAGES.TRAINED_MODELS_NODES:
path = formatTrainedModelsNodesManagementUrl('', params.pageState);
break;
case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB:
case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED:
case ML_PAGES.DATA_VISUALIZER:

View file

@ -146,8 +146,8 @@ export function TrainedModelsTableProvider({ getService }: FtrProviderContext) {
// 'Created at' will be different on each run,
// so we will just assert that the value is in the expected timestamp format.
expect(modelRow.createdAt).to.match(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
`Expected trained model row created at time to have same format as '2019-12-05 12:28:34' (got '${modelRow.createdAt}')`
/^\w{3}\s\d+,\s\d{4}\s@\s\d{2}:\d{2}:\d{2}\.\d{3}$/,
`Expected trained model row created at time to have same format as 'Dec 5, 2019 @ 12:28:34.594' (got '${modelRow.createdAt}')`
);
}