[ML] Fixes handling of built-in models (#92154)

* [ML] add description column and details tab

* [ML] restrict build-in models actions

* [ML] add description to the details tab

* [ML] add flex with wrap to the type column

* [ML] remove unused code for filtering
This commit is contained in:
Dima Arnautov 2021-02-22 18:00:15 +01:00 committed by GitHub
parent 12bb59abb0
commit a75abd7e41
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 129 additions and 175 deletions

View file

@ -29,4 +29,6 @@ export const JOB_MAP_NODE_TYPES = {
TRAINED_MODEL: 'trainedModel',
} as const;
export const BUILT_IN_MODEL_TAG = 'prepackaged';
export type JobMapNodeTypes = typeof JOB_MAP_NODE_TYPES[keyof typeof JOB_MAP_NODE_TYPES];

View file

@ -46,6 +46,7 @@ export interface TrainedModelStat {
}
export interface TrainedModelConfigResponse {
description: string;
created_by: string;
create_time: string;
default_field_map: Record<string, string>;
@ -61,7 +62,7 @@ export interface TrainedModelConfigResponse {
}
| Record<string, any>;
model_id: string;
tags: string;
tags: string[];
version: string;
inference_config?: Record<string, any>;
pipelines?: Record<string, PipelineDefinition> | null;

View file

@ -12,68 +12,6 @@ import {
Value,
DataFrameAnalyticsListRow,
} from '../pages/analytics_management/components/analytics_list/common';
import { ModelItem } from '../pages/analytics_management/components/models_management/models_list';
export function filterAnalyticsModels(
items: ModelItem[],
clauses: Array<TermClause | FieldClause>
) {
if (clauses.length === 0) {
return items;
}
// keep count of the number of matches we make as we're looping over the clauses
// we only want to return items which match all clauses, i.e. each search term is ANDed
const matches: Record<string, any> = items.reduce((p: Record<string, any>, c) => {
p[c.model_id] = {
model: c,
count: 0,
};
return p;
}, {});
clauses.forEach((c) => {
// the search term could be negated with a minus, e.g. -bananas
const bool = c.match === 'must';
let ms = [];
if (c.type === 'term') {
// filter term based clauses, e.g. bananas
// match on model_id and type
// if the term has been negated, AND the matches
if (bool === true) {
ms = items.filter(
(item) =>
stringMatch(item.model_id, c.value) === bool || stringMatch(item.type, c.value) === bool
);
} else {
ms = items.filter(
(item) =>
stringMatch(item.model_id, c.value) === bool && stringMatch(item.type, c.value) === bool
);
}
} else {
// filter other clauses, i.e. the filters for type
if (Array.isArray(c.value)) {
// type value is an array of string(s) e.g. c.value => ['classification']
ms = items.filter((item) => {
return item.type !== undefined && (c.value as Value[]).includes(item.type);
});
} else {
ms = items.filter((item) => item[c.field as keyof typeof item] === c.value);
}
}
ms.forEach((j) => matches[j.model_id].count++);
});
// loop through the matches and return only those items which have match all the clauses
const filtered = Object.values(matches)
.filter((m) => (m && m.count) >= clauses.length)
.map((m) => m.model);
return filtered;
}
export function filterAnalytics(
items: DataFrameAnalyticsListRow[],

View file

@ -66,9 +66,11 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
license_level,
pipelines,
description,
} = item;
const details = {
description,
tags,
version,
estimated_operations,
@ -104,8 +106,8 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
),
};
})
.filter(({ description }) => {
return description !== undefined;
.filter(({ description: d }) => {
return d !== undefined;
});
}
@ -365,62 +367,64 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
<>
<EuiSpacer size={'m'} />
<EuiFlexGrid columns={2} gutterSize={'m'}>
{Object.entries(pipelines).map(([pipelineName, { processors, description }]) => {
return (
<EuiFlexItem key={pipelineName}>
<EuiPanel>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size={'xs'}>
<h5>{pipelineName}</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={async () => {
const ingestPipelinesAppUrlGenerator = share.urlGenerators.getUrlGenerator(
'INGEST_PIPELINES_APP_URL_GENERATOR'
);
await navigateToUrl(
await ingestPipelinesAppUrlGenerator.createUrl({
page: 'pipeline_edit',
pipelineId: pipelineName,
absolute: true,
})
);
}}
>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.editPipelineLabel"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
{Object.entries(pipelines).map(
([pipelineName, { processors, description: pipelineDescription }]) => {
return (
<EuiFlexItem key={pipelineName}>
<EuiPanel>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size={'xs'}>
<h5>{pipelineName}</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={async () => {
const ingestPipelinesAppUrlGenerator = share.urlGenerators.getUrlGenerator(
'INGEST_PIPELINES_APP_URL_GENERATOR'
);
await navigateToUrl(
await ingestPipelinesAppUrlGenerator.createUrl({
page: 'pipeline_edit',
pipelineId: pipelineName,
absolute: true,
})
);
}}
>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.editPipelineLabel"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
{description && <EuiText>{description}</EuiText>}
<EuiSpacer size={'m'} />
<EuiTitle size={'xxs'}>
<h6>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.processorsTitle"
defaultMessage="Processors"
/>
</h6>
</EuiTitle>
<EuiCodeBlock
language="painless"
fontSize="m"
paddingSize="m"
overflowHeight={300}
isCopyable
>
{JSON.stringify(processors, null, 2)}
</EuiCodeBlock>
</EuiPanel>
</EuiFlexItem>
);
})}
{pipelineDescription && <EuiText>{pipelineDescription}</EuiText>}
<EuiSpacer size={'m'} />
<EuiTitle size={'xxs'}>
<h6>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.processorsTitle"
defaultMessage="Processors"
/>
</h6>
</EuiTitle>
<EuiCodeBlock
language="painless"
fontSize="m"
paddingSize="m"
overflowHeight={300}
isCopyable
>
{JSON.stringify(processors, null, 2)}
</EuiCodeBlock>
</EuiPanel>
</EuiFlexItem>
);
}
)}
</EuiFlexGrid>
</>
),

View file

@ -9,6 +9,7 @@ export * from './models_list';
export const ModelsTableToConfigMapping = {
id: 'model_id',
description: 'description',
createdAt: 'create_time',
type: 'type',
} as const;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC, useState, useCallback, useEffect, useMemo } from 'react';
import React, { FC, useState, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
@ -14,7 +14,6 @@ import {
EuiFlexItem,
EuiTitle,
EuiButton,
EuiSearchBar,
EuiSpacer,
EuiButtonIcon,
EuiBadge,
@ -48,18 +47,17 @@ import {
refreshAnalyticsList$,
useRefreshAnalyticsList,
} from '../../../../common';
import { useTableSettings } from '../analytics_list/use_table_settings';
import { filterAnalyticsModels } from '../../../../common/search_bar_filters';
import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator';
import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics';
import { timeFormatter } from '../../../../../../../common/util/date_utils';
import { ListingPageUrlState } from '../../../../../../../common/types/common';
import { usePageUrlState } from '../../../../../util/url_state';
import { BUILT_IN_MODEL_TAG } from '../../../../../../../common/constants/data_frame_analytics';
type Stats = Omit<TrainedModelStat, 'model_id'>;
export type ModelItem = TrainedModelConfigResponse & {
type?: string;
type?: string[];
stats?: Stats;
pipelines?: ModelPipelines['pipelines'] | null;
};
@ -73,6 +71,11 @@ export const getDefaultModelsListState = (): ListingPageUrlState => ({
sortDirection: 'asc',
});
export const BUILT_IN_MODEL_TYPE = i18n.translate(
'xpack.ml.trainedModels.modelsList.builtInModelLabel',
{ defaultMessage: 'built-in' }
);
export const ModelsList: FC = () => {
const {
services: {
@ -99,7 +102,6 @@ export const ModelsList: FC = () => {
const trainedModelsApiService = useTrainedModelsApiService();
const { toasts } = useNotifications();
const [filteredModels, setFilteredModels] = useState<ModelItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [items, setItems] = useState<ModelItem[]>([]);
const [selectedModels, setSelectedModels] = useState<ModelItem[]>([]);
@ -111,36 +113,15 @@ export const ModelsList: FC = () => {
const mlUrlGenerator = useMlUrlGenerator();
const navigateToPath = useNavigateToPath();
const updateFilteredItems = (queryClauses: any) => {
if (queryClauses.length) {
const filtered = filterAnalyticsModels(items, queryClauses);
setFilteredModels(filtered);
} else {
setFilteredModels(items);
}
};
const filterList = () => {
if (searchQueryText !== '') {
const query = EuiSearchBar.Query.parse(searchQueryText);
let clauses: any = [];
if (query && query.ast !== undefined && query.ast.clauses !== undefined) {
clauses = query.ast.clauses;
}
updateFilteredItems(clauses);
} else {
updateFilteredItems([]);
}
};
useEffect(() => {
filterList();
}, [searchQueryText, items]);
const isBuiltInModel = useCallback(
(item: ModelItem) => item.tags.includes(BUILT_IN_MODEL_TAG),
[]
);
/**
* Fetches inference trained models.
* Fetches trained models.
*/
const fetchData = useCallback(async () => {
const fetchModelsData = useCallback(async () => {
try {
const response = await trainedModelsApiService.getTrainedModels(undefined, {
with_pipelines: true,
@ -151,10 +132,16 @@ export const ModelsList: FC = () => {
const expandedItemsToRefresh = [];
for (const model of response) {
const tableItem = {
const tableItem: ModelItem = {
...model,
// Extract model types
...(typeof model.inference_config === 'object'
? { type: Object.keys(model.inference_config)[0] }
? {
type: [
...Object.keys(model.inference_config),
...(isBuiltInModel(model) ? [BUILT_IN_MODEL_TYPE] : []),
],
}
: {}),
};
newItems.push(tableItem);
@ -190,7 +177,7 @@ export const ModelsList: FC = () => {
// Subscribe to the refresh observable to trigger reloading the model list.
useRefreshAnalyticsList({
isLoading: setIsLoading,
onRefresh: fetchData,
onRefresh: fetchModelsData,
});
const modelsStats: ModelsBarStats = useMemo(() => {
@ -369,7 +356,7 @@ export const ModelsList: FC = () => {
onClick: async (model) => {
await prepareModelsForDeletion([model]);
},
available: (item) => canDeleteDataFrameAnalytics,
available: (item) => canDeleteDataFrameAnalytics && !isBuiltInModel(item),
enabled: (item) => {
// TODO check for permissions to delete ingest pipelines.
// ATM undefined means pipelines fetch failed server-side.
@ -418,6 +405,15 @@ export const ModelsList: FC = () => {
sortable: true,
truncateText: true,
},
{
field: ModelsTableToConfigMapping.description,
width: '350px',
name: i18n.translate('xpack.ml.trainedModels.modelsList.modelDescriptionHeader', {
defaultMessage: 'Description',
}),
sortable: false,
truncateText: true,
},
{
field: ModelsTableToConfigMapping.type,
name: i18n.translate('xpack.ml.trainedModels.modelsList.typeHeader', {
@ -425,7 +421,15 @@ export const ModelsList: FC = () => {
}),
sortable: true,
align: 'left',
render: (type: string) => <EuiBadge color="hollow">{type}</EuiBadge>,
render: (types: string[]) => (
<EuiFlexGroup gutterSize={'xs'} wrap>
{types.map((type) => (
<EuiFlexItem key={type} grow={false}>
<EuiBadge color="hollow">{type}</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
),
},
{
field: ModelsTableToConfigMapping.createdAt,
@ -459,12 +463,6 @@ export const ModelsList: FC = () => {
]
: [];
const { onTableChange, pagination, sorting } = useTableSettings<ModelItem>(
filteredModels,
pageState,
updatePageState
);
const toolsLeft = (
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
@ -496,15 +494,27 @@ export const ModelsList: FC = () => {
const selection: EuiTableSelectionType<ModelItem> | undefined = isSelectionAllowed
? {
selectableMessage: (selectable, item) => {
return selectable
? i18n.translate('xpack.ml.trainedModels.modelsList.selectableMessage', {
defaultMessage: 'Select a model',
})
: i18n.translate('xpack.ml.trainedModels.modelsList.disableSelectableMessage', {
defaultMessage: 'Model has associated pipelines',
});
if (selectable) {
return i18n.translate('xpack.ml.trainedModels.modelsList.selectableMessage', {
defaultMessage: 'Select a model',
});
}
if (Array.isArray(item.pipelines) && item.pipelines.length > 0) {
return i18n.translate('xpack.ml.trainedModels.modelsList.disableSelectableMessage', {
defaultMessage: 'Model has associated pipelines',
});
}
if (isBuiltInModel(item)) {
return i18n.translate('xpack.ml.trainedModels.modelsList.builtInModelMessage', {
defaultMessage: 'Built-in model',
});
}
return '';
},
selectable: (item) => !item.pipelines,
selectable: (item) => !item.pipelines && !isBuiltInModel(item),
onSelectionChange: (selectedItems) => {
setSelectedModels(selectedItems);
},
@ -534,6 +544,7 @@ export const ModelsList: FC = () => {
}
: {}),
};
return (
<>
<EuiSpacer size="m" />
@ -556,9 +567,6 @@ export const ModelsList: FC = () => {
items={items}
itemId={ModelsTableToConfigMapping.id}
loading={isLoading}
onTableChange={onTableChange}
pagination={pagination}
sorting={sorting}
search={search}
selection={selection}
rowProps={(item) => ({