[ML] DF Analytics jobs list: persist pagination through refresh interval (#75996)

* wip: switch analyticsList inMemoryTable to basic and implement search bar

* move basicTable settings to custom hook and update types

* update types

* add types for empty prompt

* ensure sorting works

* add refresh to analytics management list

* ensure table still updates editing job
This commit is contained in:
Melissa Alvarez 2020-09-02 09:11:05 -04:00 committed by GitHub
parent 4762cf56f5
commit 70cea48718
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 446 additions and 138 deletions

View file

@ -5,17 +5,13 @@
*/
import React, { FC, useCallback, useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import {
Direction,
EuiButton,
EuiCallOut,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiBasicTable,
EuiSearchBar,
EuiSearchBarProps,
EuiSpacer,
} from '@elastic/eui';
@ -43,6 +39,39 @@ import {
getGroupQueryText,
} from '../../../../../jobs/jobs_list/components/utils';
import { SourceSelection } from '../source_selection';
import { filterAnalytics, AnalyticsSearchBar } from '../analytics_search_bar';
import { AnalyticsEmptyPrompt } from './empty_prompt';
import { useTableSettings } from './use_table_settings';
import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button';
const filters: EuiSearchBarProps['filters'] = [
{
type: 'field_value_selection',
field: 'job_type',
name: i18n.translate('xpack.ml.dataframe.analyticsList.typeFilter', {
defaultMessage: 'Type',
}),
multiSelect: 'or',
options: Object.values(ANALYSIS_CONFIG_TYPE).map((val) => ({
value: val,
name: val,
view: getJobTypeBadge(val),
})),
},
{
type: 'field_value_selection',
field: 'state',
name: i18n.translate('xpack.ml.dataframe.analyticsList.statusFilter', {
defaultMessage: 'Status',
}),
multiSelect: 'or',
options: Object.values(DATA_FRAME_TASK_STATE).map((val) => ({
value: val,
name: val,
view: getTaskStateBadge(val),
})),
},
];
function getItemIdToExpandedRowMap(
itemIds: DataFrameAnalyticsId[],
@ -70,23 +99,23 @@ 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 [searchQueryText, setSearchQueryText] = useState('');
const [analytics, setAnalytics] = useState<DataFrameAnalyticsListRow[]>([]);
const [analyticsStats, setAnalyticsStats] = useState<AnalyticStatsBarStats | undefined>(
undefined
);
const [expandedRowItemIds, setExpandedRowItemIds] = useState<DataFrameAnalyticsId[]>([]);
const [errorMessage, setErrorMessage] = useState<any>(undefined);
const [searchError, setSearchError] = useState<any>(undefined);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState<string>(DataFrameAnalyticsListColumn.id);
const [sortDirection, setSortDirection] = useState<Direction>('asc');
// Query text/job_id based on url but only after getAnalytics is done first
// selectedJobIdFromUrlInitialized makes sure the query is only run once since analytics is being refreshed constantly
const [selectedIdFromUrlInitialized, setSelectedIdFromUrlInitialized] = useState(false);
const disabled =
!checkPermission('canCreateDataFrameAnalytics') ||
@ -100,9 +129,29 @@ export const DataFrameAnalyticsList: FC<Props> = ({
blockRefresh
);
// Query text/job_id based on url but only after getAnalytics is done first
// selectedJobIdFromUrlInitialized makes sure the query is only run once since analytics is being refreshed constantly
const [selectedIdFromUrlInitialized, setSelectedIdFromUrlInitialized] = useState(false);
const setQueryClauses = (queryClauses: any) => {
if (queryClauses.length) {
const filtered = filterAnalytics(analytics, queryClauses);
setFilteredAnalytics({ active: true, items: filtered });
} else {
setFilteredAnalytics({ active: false, items: [] });
}
};
const filterList = () => {
if (searchQueryText !== '' && selectedIdFromUrlInitialized === true) {
// trigger table filtering with query for job id to trigger table filter
const query = EuiSearchBar.Query.parse(searchQueryText);
let clauses: any = [];
if (query && query.ast !== undefined && query.ast.clauses !== undefined) {
clauses = query.ast.clauses;
}
setQueryClauses(clauses);
} else {
setQueryClauses([]);
}
};
useEffect(() => {
if (selectedIdFromUrlInitialized === false && analytics.length > 0) {
const { jobId, groupIds } = getSelectedIdFromUrl(window.location.href);
@ -116,9 +165,15 @@ export const DataFrameAnalyticsList: FC<Props> = ({
setSelectedIdFromUrlInitialized(true);
setSearchQueryText(queryText);
} else {
filterList();
}
}, [selectedIdFromUrlInitialized, analytics]);
useEffect(() => {
filterList();
}, [selectedIdFromUrlInitialized, searchQueryText]);
const getAnalyticsCallback = useCallback(() => getAnalytics(true), []);
// Subscribe to the refresh observable to trigger reloading the analytics list.
@ -137,6 +192,10 @@ export const DataFrameAnalyticsList: FC<Props> = ({
isMlEnabledInSpace
);
const { onTableChange, pageOfItems, pagination, sorting } = useTableSettings(
filteredAnalytics.active ? filteredAnalytics.items : analytics
);
// 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.
if (!isInitialized) {
@ -160,34 +219,10 @@ export const DataFrameAnalyticsList: FC<Props> = ({
if (analytics.length === 0) {
return (
<>
<EuiEmptyPrompt
iconType="createAdvancedJob"
title={
<h2>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptTitle', {
defaultMessage: 'Create your first data frame analytics job',
})}
</h2>
}
actions={
!isManagementTable
? [
<EuiButton
onClick={() => setIsSourceIndexModalVisible(true)}
isDisabled={disabled}
color="primary"
iconType="plusInCircle"
fill
data-test-subj="mlAnalyticsCreateFirstButton"
>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', {
defaultMessage: 'Create job',
})}
</EuiButton>,
]
: []
}
data-test-subj="mlNoDataFrameAnalyticsFound"
<AnalyticsEmptyPrompt
isManagementTable={isManagementTable}
disabled={disabled}
onCreateFirstJobClick={() => setIsSourceIndexModalVisible(true)}
/>
{isSourceIndexModalVisible === true && (
<SourceSelection onClose={() => setIsSourceIndexModalVisible(false)} />
@ -196,95 +231,32 @@ export const DataFrameAnalyticsList: FC<Props> = ({
);
}
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, analytics);
const pagination = {
initialPageIndex: pageIndex,
initialPageSize: pageSize,
totalItemCount: analytics.length,
pageSizeOptions: [10, 20, 50],
hidePerPageOptions: false,
};
const stats = analyticsStats && (
<EuiFlexItem grow={false}>
<StatsBar stats={analyticsStats} dataTestSub={'mlAnalyticsStatsBar'} />
</EuiFlexItem>
);
const handleSearchOnChange: EuiSearchBarProps['onChange'] = (search) => {
if (search.error !== null) {
setSearchError(search.error.message);
return false;
}
setSearchError(undefined);
setSearchQueryText(search.queryText);
return true;
};
const search: EuiSearchBarProps = {
query: searchQueryText,
onChange: handleSearchOnChange,
box: {
incremental: true,
},
filters: [
{
type: 'field_value_selection',
field: 'job_type',
name: i18n.translate('xpack.ml.dataframe.analyticsList.typeFilter', {
defaultMessage: 'Type',
}),
multiSelect: 'or',
options: Object.values(ANALYSIS_CONFIG_TYPE).map((val) => ({
value: val,
name: val,
view: getJobTypeBadge(val),
})),
},
{
type: 'field_value_selection',
field: 'state',
name: i18n.translate('xpack.ml.dataframe.analyticsList.statusFilter', {
defaultMessage: 'Status',
}),
multiSelect: 'or',
options: Object.values(DATA_FRAME_TASK_STATE).map((val) => ({
value: val,
name: val,
view: getTaskStateBadge(val),
})),
},
],
};
const onTableChange: EuiInMemoryTable<DataFrameAnalyticsListRow>['onTableChange'] = ({
page = { index: 0, size: 10 },
sort = { field: DataFrameAnalyticsListColumn.id, direction: 'asc' },
}) => {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
};
const managementStats = (
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween">
{stats}
<EuiFlexItem grow={false}>
<RefreshAnalyticsListButton />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
return (
<>
{modals}
<EuiSpacer size="m" />
{!isManagementTable && <EuiSpacer size="m" />}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
{analyticsStats && (
<EuiFlexItem grow={false}>
<StatsBar stats={analyticsStats} dataTestSub={'mlAnalyticsStatsBar'} />
</EuiFlexItem>
)}
</EuiFlexItem>
{!isManagementTable && stats}
{isManagementTable && managementStats}
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="s">
{!isManagementTable && (
@ -300,22 +272,25 @@ export const DataFrameAnalyticsList: FC<Props> = ({
</EuiFlexGroup>
<EuiSpacer size="m" />
<div data-test-subj="mlAnalyticsTableContainer">
<EuiInMemoryTable
allowNeutralSort={false}
<AnalyticsSearchBar
filters={filters}
searchQueryText={searchQueryText}
setSearchQueryText={setSearchQueryText}
/>
<EuiSpacer size="l" />
<EuiBasicTable<DataFrameAnalyticsListRow>
className="mlAnalyticsTable"
columns={columns}
error={searchError}
hasActions={false}
isExpandable={true}
isSelectable={false}
items={analytics}
items={pageOfItems}
itemId={DataFrameAnalyticsListColumn.id}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
loading={isLoading}
onTableChange={onTableChange}
pagination={pagination}
onChange={onTableChange}
pagination={pagination!}
sorting={sorting}
search={search}
data-test-subj={isLoading ? 'mlAnalyticsTable loading' : 'mlAnalyticsTable loaded'}
rowProps={(item) => ({
'data-test-subj': `mlAnalyticsTableRow row-${item.id}`,

View file

@ -26,6 +26,7 @@ export type Clause = Parameters<typeof Query['isMust']>[0];
type ExtractClauseType<T> = T extends (x: any) => x is infer Type ? Type : never;
export type TermClause = ExtractClauseType<typeof Ast['Term']['isInstance']>;
export type FieldClause = ExtractClauseType<typeof Ast['Field']['isInstance']>;
export type Value = Parameters<typeof Ast['Term']['must']>[0];
interface ProgressSection {
phase: string;

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface Props {
disabled: boolean;
isManagementTable: boolean;
onCreateFirstJobClick: () => void;
}
export const AnalyticsEmptyPrompt: FC<Props> = ({
disabled,
isManagementTable,
onCreateFirstJobClick,
}) => (
<EuiEmptyPrompt
iconType="createAdvancedJob"
title={
<h2>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptTitle', {
defaultMessage: 'Create your first data frame analytics job',
})}
</h2>
}
actions={
!isManagementTable
? [
<EuiButton
onClick={onCreateFirstJobClick}
isDisabled={disabled}
color="primary"
iconType="plusInCircle"
fill
data-test-subj="mlAnalyticsCreateFirstButton"
>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', {
defaultMessage: 'Create job',
})}
</EuiButton>,
]
: []
}
data-test-subj="mlNoDataFrameAnalyticsFound"
/>
);

View file

@ -23,7 +23,6 @@ import { getJobIdUrl, TAB_IDS } from '../../../../../util/get_selected_ids_url';
import { getAnalysisType, DataFrameAnalyticsId } from '../../../../common';
import {
getDataFrameAnalyticsProgress,
getDataFrameAnalyticsProgressPhase,
isDataFrameAnalyticsFailed,
isDataFrameAnalyticsRunning,
@ -76,7 +75,6 @@ export const progressColumn = {
name: i18n.translate('xpack.ml.dataframe.analyticsList.progress', {
defaultMessage: 'Progress',
}),
sortable: (item: DataFrameAnalyticsListRow) => getDataFrameAnalyticsProgress(item.stats),
truncateText: true,
render(item: DataFrameAnalyticsListRow) {
const { currentPhase, progress, totalPhases } = getDataFrameAnalyticsProgressPhase(item.stats);

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
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];
const jobPropertyMap = {
ID: 'id',
Status: 'state',
Type: 'job_type',
};
interface AnalyticsBasicTableSettings {
pageIndex: number;
pageSize: number;
totalItemCount: number;
hidePerPageOptions: boolean;
sortField: string;
sortDirection: Direction;
}
interface UseTableSettingsReturnValue {
onTableChange: EuiBasicTableProps<DataFrameAnalyticsListRow>['onChange'];
pageOfItems: DataFrameAnalyticsListRow[];
pagination: EuiBasicTableProps<DataFrameAnalyticsListRow>['pagination'];
sorting: EuiTableSortingType<any>;
}
export function useTableSettings(items: DataFrameAnalyticsListRow[]): UseTableSettingsReturnValue {
const [tableSettings, setTableSettings] = useState<AnalyticsBasicTableSettings>({
pageIndex: 0,
pageSize: PAGE_SIZE,
totalItemCount: 0,
hidePerPageOptions: false,
sortField: DataFrameAnalyticsListColumn.id,
sortDirection: 'asc',
});
const getPageOfItems = (
list: any[],
index: number,
size: number,
sortField: string,
sortDirection: Direction
) => {
list = sortBy(list, (item) =>
get(item, jobPropertyMap[sortField as keyof typeof jobPropertyMap] || sortField)
);
list = sortDirection === 'asc' ? list : list.reverse();
const listLength = list.length;
let pageStart = index * size;
if (pageStart >= listLength && listLength !== 0) {
// if the page start is larger than the number of items due to
// filters being applied or items being deleted, calculate a new page start
pageStart = Math.floor((listLength - 1) / size) * size;
setTableSettings({ ...tableSettings, pageIndex: pageStart / size });
}
return {
pageOfItems: list.slice(pageStart, pageStart + size),
totalItemCount: listLength,
};
};
const onTableChange = ({
page = { index: 0, size: PAGE_SIZE },
sort = { field: DataFrameAnalyticsListColumn.id, direction: 'asc' },
}: {
page?: { index: number; size: number };
sort?: { field: string; direction: Direction };
}) => {
const { index, size } = page;
const { field, direction } = sort;
setTableSettings({
...tableSettings,
pageIndex: index,
pageSize: size,
sortField: field,
sortDirection: direction,
});
};
const { pageIndex, pageSize, sortField, sortDirection } = tableSettings;
const { pageOfItems, totalItemCount } = getPageOfItems(
items,
pageIndex,
pageSize,
sortField,
sortDirection
);
const pagination = {
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions: PAGE_SIZE_OPTIONS,
};
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
return { onTableChange, pageOfItems, pagination, sorting };
}

View file

@ -0,0 +1,157 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Dispatch, SetStateAction, FC, Fragment, useState } from 'react';
import {
EuiSearchBar,
EuiSearchBarProps,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { stringMatch } from '../../../../../util/string_utils';
import {
TermClause,
FieldClause,
Value,
DataFrameAnalyticsListRow,
} from '../analytics_list/common';
export function filterAnalytics(
items: DataFrameAnalyticsListRow[],
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.id] = {
job: 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 js = [];
if (c.type === 'term') {
// filter term based clauses, e.g. bananas
// match on id, description and memory_status
// if the term has been negated, AND the matches
if (bool === true) {
js = items.filter(
(item) =>
stringMatch(item.id, c.value) === bool ||
stringMatch(item.config.description, c.value) === bool ||
stringMatch(item.stats?.memory_usage?.status, c.value) === bool
);
} else {
js = items.filter(
(item) =>
stringMatch(item.id, c.value) === bool &&
stringMatch(item.config.description, c.value) === bool &&
stringMatch(item.stats?.memory_usage?.status, c.value) === bool
);
}
} else {
// filter other clauses, i.e. the filters for type and status
if (Array.isArray(c.value)) {
// job type value and status value are an array of string(s) e.g. c.value => ['failed', 'stopped']
js = items.filter((item) =>
(c.value as Value[]).includes(
item[c.field as keyof Pick<typeof item, 'job_type' | 'state'>]
)
);
} else {
js = items.filter(
(item) => item[c.field as keyof Pick<typeof item, 'job_type' | 'state'>] === c.value
);
}
}
js.forEach((j) => matches[j.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.job);
return filtered;
}
function getError(errorMessage: string | null) {
if (errorMessage) {
return i18n.translate('xpack.ml.analyticList.searchBar.invalidSearchErrorMessage', {
defaultMessage: 'Invalid search: {errorMessage}',
values: { errorMessage },
});
}
return '';
}
interface Props {
filters: EuiSearchBarProps['filters'];
searchQueryText: string;
setSearchQueryText: Dispatch<SetStateAction<string>>;
}
export const AnalyticsSearchBar: FC<Props> = ({ filters, searchQueryText, setSearchQueryText }) => {
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const onChange: EuiSearchBarProps['onChange'] = ({ query, error }) => {
if (error) {
setErrorMessage(error.message);
} else if (query !== null && query.text !== undefined) {
setSearchQueryText(query.text);
setErrorMessage(null);
}
};
return (
<EuiFlexGroup direction="column">
<EuiFlexItem data-test-subj="mlAnalyticsSearchBar" grow={false}>
{searchQueryText === undefined && (
<EuiSearchBar
box={{
incremental: true,
}}
filters={filters}
onChange={onChange}
className="mlAnalyitcsSearchBar"
/>
)}
{searchQueryText !== undefined && (
<EuiSearchBar
box={{
incremental: true,
}}
defaultQuery={searchQueryText}
filters={filters}
onChange={onChange}
className="mlAnalyitcsSearchBar"
/>
)}
<EuiFormRow
fullWidth
isInvalid={errorMessage !== null}
error={getError(errorMessage)}
style={{ maxHeight: '0px' }}
>
<Fragment />
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { AnalyticsSearchBar, filterAnalytics } from './analytics_search_bar';

View file

@ -7,7 +7,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { sortBy } from 'lodash';
import sortBy from 'lodash/sortBy';
import moment from 'moment';
import { toLocaleString } from '../../../../util/string_utils';