[ML] DF Analytics models list: persist pagination through refresh interval (#76695)

* ensure wizard error shows up correctly

* wip: switch to basic table for model management

* add selection and multi job action to models list

* update error extraction function

* use generic types in hook

* simplify filtered items
This commit is contained in:
Melissa Alvarez 2020-09-08 13:57:10 -04:00 committed by GitHub
parent b149a60554
commit 8bc6898c33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 210 additions and 139 deletions

View file

@ -135,7 +135,14 @@ export const extractErrorProperties = (
typeof error.body.attributes === 'object' &&
error.body.attributes.body?.status !== undefined
) {
statusCode = error.body.attributes.body?.status;
statusCode = error.body.attributes.body.status;
if (typeof error.body.attributes.body.error?.reason === 'string') {
return {
message: error.body.attributes.body.error.reason,
statusCode,
};
}
}
if (typeof error.body.message === 'string') {

View file

@ -209,7 +209,6 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
let unsupportedFieldsErrorMessage;
if (
jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION &&
errorMessage.includes('status_exception') &&
(errorMessage.includes('must have at most') || errorMessage.includes('must have at least'))
) {
maxDistinctValuesErrorMessage = errorMessage;

View file

@ -99,13 +99,7 @@ export const DataFrameAnalyticsList: FC<Props> = ({
const [isInitialized, setIsInitialized] = useState(false);
const [isSourceIndexModalVisible, setIsSourceIndexModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [filteredAnalytics, setFilteredAnalytics] = useState<{
active: boolean;
items: DataFrameAnalyticsListRow[];
}>({
active: false,
items: [],
});
const [filteredAnalytics, setFilteredAnalytics] = useState<DataFrameAnalyticsListRow[]>([]);
const [searchQueryText, setSearchQueryText] = useState('');
const [analytics, setAnalytics] = useState<DataFrameAnalyticsListRow[]>([]);
const [analyticsStats, setAnalyticsStats] = useState<AnalyticStatsBarStats | undefined>(
@ -129,12 +123,12 @@ export const DataFrameAnalyticsList: FC<Props> = ({
blockRefresh
);
const setQueryClauses = (queryClauses: any) => {
const updateFilteredItems = (queryClauses: any) => {
if (queryClauses.length) {
const filtered = filterAnalytics(analytics, queryClauses);
setFilteredAnalytics({ active: true, items: filtered });
setFilteredAnalytics(filtered);
} else {
setFilteredAnalytics({ active: false, items: [] });
setFilteredAnalytics(analytics);
}
};
@ -146,9 +140,9 @@ export const DataFrameAnalyticsList: FC<Props> = ({
if (query && query.ast !== undefined && query.ast.clauses !== undefined) {
clauses = query.ast.clauses;
}
setQueryClauses(clauses);
updateFilteredItems(clauses);
} else {
setQueryClauses([]);
updateFilteredItems([]);
}
};
@ -192,9 +186,9 @@ export const DataFrameAnalyticsList: FC<Props> = ({
isMlEnabledInSpace
);
const { onTableChange, pageOfItems, pagination, sorting } = useTableSettings(
filteredAnalytics.active ? filteredAnalytics.items : analytics
);
const { onTableChange, pageOfItems, pagination, sorting } = useTableSettings<
DataFrameAnalyticsListRow
>(DataFrameAnalyticsListColumn.id, filteredAnalytics);
// Before the analytics have been loaded for the first time, display the loading indicator only.
// Otherwise a user would see 'No data frame analytics found' during the initial loading.

View file

@ -8,7 +8,6 @@ import { useState } from 'react';
import { Direction, EuiBasicTableProps, EuiTableSortingType } from '@elastic/eui';
import sortBy from 'lodash/sortBy';
import get from 'lodash/get';
import { DataFrameAnalyticsListColumn, DataFrameAnalyticsListRow } from './common';
const PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [10, 25, 50];
@ -19,37 +18,59 @@ const jobPropertyMap = {
Type: 'job_type',
};
interface AnalyticsBasicTableSettings {
// Copying from EUI EuiBasicTable types as type is not correctly picked up for table's onChange
// Can be removed when https://github.com/elastic/eui/issues/4011 is addressed in EUI
export interface Criteria<T> {
page?: {
index: number;
size: number;
};
sort?: {
field: keyof T;
direction: Direction;
};
}
export interface CriteriaWithPagination<T> extends Criteria<T> {
page: {
index: number;
size: number;
};
}
interface AnalyticsBasicTableSettings<T> {
pageIndex: number;
pageSize: number;
totalItemCount: number;
hidePerPageOptions: boolean;
sortField: string;
sortField: keyof T;
sortDirection: Direction;
}
interface UseTableSettingsReturnValue {
onTableChange: EuiBasicTableProps<DataFrameAnalyticsListRow>['onChange'];
pageOfItems: DataFrameAnalyticsListRow[];
pagination: EuiBasicTableProps<DataFrameAnalyticsListRow>['pagination'];
interface UseTableSettingsReturnValue<T> {
onTableChange: EuiBasicTableProps<T>['onChange'];
pageOfItems: T[];
pagination: EuiBasicTableProps<T>['pagination'];
sorting: EuiTableSortingType<any>;
}
export function useTableSettings(items: DataFrameAnalyticsListRow[]): UseTableSettingsReturnValue {
const [tableSettings, setTableSettings] = useState<AnalyticsBasicTableSettings>({
export function useTableSettings<TypeOfItem>(
sortByField: keyof TypeOfItem,
items: TypeOfItem[]
): UseTableSettingsReturnValue<TypeOfItem> {
const [tableSettings, setTableSettings] = useState<AnalyticsBasicTableSettings<TypeOfItem>>({
pageIndex: 0,
pageSize: PAGE_SIZE,
totalItemCount: 0,
hidePerPageOptions: false,
sortField: DataFrameAnalyticsListColumn.id,
sortField: sortByField,
sortDirection: 'asc',
});
const getPageOfItems = (
list: any[],
list: TypeOfItem[],
index: number,
size: number,
sortField: string,
sortField: keyof TypeOfItem,
sortDirection: Direction
) => {
list = sortBy(list, (item) =>
@ -72,13 +93,10 @@ export function useTableSettings(items: DataFrameAnalyticsListRow[]): UseTableSe
};
};
const onTableChange = ({
const onTableChange: EuiBasicTableProps<TypeOfItem>['onChange'] = ({
page = { index: 0, size: PAGE_SIZE },
sort = { field: DataFrameAnalyticsListColumn.id, direction: 'asc' },
}: {
page?: { index: number; size: number };
sort?: { field: string; direction: Direction };
}) => {
sort = { field: sortByField, direction: 'asc' },
}: CriteriaWithPagination<TypeOfItem>) => {
const { index, size } = page;
const { field, direction } = sort;

View file

@ -20,6 +20,68 @@ import {
Value,
DataFrameAnalyticsListRow,
} from '../analytics_list/common';
import { ModelItem } from '../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

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { AnalyticsSearchBar, filterAnalytics } from './analytics_search_bar';
export { AnalyticsSearchBar, filterAnalytics, filterAnalyticsModels } from './analytics_search_bar';

View file

@ -4,20 +4,20 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useState, useCallback, useMemo } from 'react';
import React, { FC, useState, useCallback, useEffect, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
Direction,
EuiBasicTable,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiTitle,
EuiButton,
EuiSearchBarProps,
EuiSearchBar,
EuiSpacer,
EuiButtonIcon,
EuiBadge,
SearchFilterConfig,
} from '@elastic/eui';
// @ts-ignore
import { formatDate } from '@elastic/eui/lib/services/format';
@ -42,6 +42,8 @@ import {
refreshAnalyticsList$,
useRefreshAnalyticsList,
} from '../../../../common';
import { useTableSettings } from '../analytics_list/use_table_settings';
import { filterAnalyticsModels, AnalyticsSearchBar } from '../analytics_search_bar';
type Stats = Omit<TrainedModelStat, 'model_id'>;
@ -66,22 +68,41 @@ export const ModelsList: FC = () => {
const { toasts } = useNotifications();
const [searchQueryText, setSearchQueryText] = useState('');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState<string>(ModelsTableToConfigMapping.id);
const [sortDirection, setSortDirection] = useState<Direction>('asc');
const [filteredModels, setFilteredModels] = useState<ModelItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [items, setItems] = useState<ModelItem[]>([]);
const [selectedModels, setSelectedModels] = useState<ModelItem[]>([]);
const [modelsToDelete, setModelsToDelete] = useState<ModelItemFull[]>([]);
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, JSX.Element>>(
{}
);
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]);
/**
* Fetches inference trained models.
*/
@ -355,91 +376,51 @@ export const ModelsList: FC = () => {
},
];
const pagination = {
initialPageIndex: pageIndex,
initialPageSize: pageSize,
totalItemCount: items.length,
pageSizeOptions: [10, 20, 50],
hidePerPageOptions: false,
};
const filters: SearchFilterConfig[] =
inferenceTypesOptions && inferenceTypesOptions.length > 0
? [
{
type: 'field_value_selection',
field: 'type',
name: i18n.translate('xpack.ml.dataframe.analyticsList.typeFilter', {
defaultMessage: 'Type',
}),
multiSelect: 'or',
options: inferenceTypesOptions,
},
]
: [];
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
const search: EuiSearchBarProps = {
query: searchQueryText,
onChange: (searchChange) => {
if (searchChange.error !== null) {
return false;
}
setSearchQueryText(searchChange.queryText);
return true;
},
box: {
incremental: true,
},
...(inferenceTypesOptions && inferenceTypesOptions.length > 0
? {
filters: [
{
type: 'field_value_selection',
field: 'type',
name: i18n.translate('xpack.ml.dataframe.analyticsList.typeFilter', {
defaultMessage: 'Type',
}),
multiSelect: 'or',
options: inferenceTypesOptions,
},
],
}
: {}),
...(selectedModels.length > 0
? {
toolsLeft: (
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h5>
<FormattedMessage
id="xpack.ml.inference.modelsList.selectedModelsMessage"
defaultMessage="{modelsCount, plural, one{# model} other {# models}} selected"
values={{ modelsCount: selectedModels.length }}
/>
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
color="danger"
onClick={prepareModelsForDeletion.bind(null, selectedModels)}
>
<FormattedMessage
id="xpack.ml.inference.modelsList.deleteModelsButtonLabel"
defaultMessage="Delete"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
}
: {}),
};
const { onTableChange, pageOfItems, pagination, sorting } = useTableSettings<ModelItem>(
ModelsTableToConfigMapping.id,
filteredModels
);
const onTableChange: EuiInMemoryTable<ModelItem>['onTableChange'] = ({
page = { index: 0, size: 10 },
sort = { field: ModelsTableToConfigMapping.id, direction: 'asc' },
}) => {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
};
const toolsLeft = (
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h5>
<FormattedMessage
id="xpack.ml.inference.modelsList.selectedModelsMessage"
defaultMessage="{modelsCount, plural, one{# model} other {# models}} selected"
values={{ modelsCount: selectedModels.length }}
/>
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton color="danger" onClick={prepareModelsForDeletion.bind(null, selectedModels)}>
<FormattedMessage
id="xpack.ml.inference.modelsList.deleteModelsButtonLabel"
defaultMessage="Delete"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
const isSelectionAllowed = canDeleteDataFrameAnalytics;
@ -473,21 +454,31 @@ export const ModelsList: FC = () => {
</EuiFlexGroup>
<EuiSpacer size="m" />
<div data-test-subj="mlModelsTableContainer">
<EuiInMemoryTable
allowNeutralSort={false}
<EuiFlexGroup alignItems="center">
{selectedModels.length > 0 && toolsLeft}
<EuiFlexItem>
<AnalyticsSearchBar
filters={filters}
searchQueryText={searchQueryText}
setSearchQueryText={setSearchQueryText}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiBasicTable<ModelItem>
columns={columns}
hasActions={true}
isExpandable={true}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isSelectable={false}
items={items}
items={pageOfItems}
itemId={ModelsTableToConfigMapping.id}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
loading={isLoading}
onTableChange={onTableChange}
pagination={pagination}
sorting={sorting}
search={search}
onChange={onTableChange}
selection={selection}
pagination={pagination!}
sorting={sorting}
data-test-subj={isLoading ? 'mlModelsTable loading' : 'mlModelsTable loaded'}
rowProps={(item) => ({
'data-test-subj': `mlModelsTableRow row-${item.model_id}`,
})}