Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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') ||
Expand All @@ -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);
Expand All @@ -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.
Expand All @@ -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) {
Expand All @@ -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)} />
Expand All @@ -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 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 stats = analyticsStats && (
<EuiFlexItem grow={false}>
<StatsBar stats={analyticsStats} dataTestSub={'mlAnalyticsStatsBar'} />
</EuiFlexItem>
);

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 && (
Expand All @@ -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}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
/>
);
Loading