[ML] Data Frames: Jobs List Progress Bar (#36362) (#36427)

- Adds a new column with a progress bar to the data frames jobs list.
- Updated the data frame jobs list empty table message to get rid of the Here be dragons ... message.
- Changes MINIMUM_REFRESH_INTERVAL_MS from 5000 to 1000 in ml/common/constants/jobs_list.js->ts. This change also affects the anomaly detection jobs list. It fixes a bug where setting the timefilter interval to less than 5s would stop updating the jobs list. This was a regression of a change in timefilter. Previously the minimum allowed interval settings was 5s.
- Now the correct timefilter based interval picker gets initialized and displayed for the data frame jobs list. The code is a replication of what is used for the anomaly detection job list using a custom hook in use_refresh_interval.ts.
This commit is contained in:
Walter Rafelsberger 2019-05-10 13:22:45 +02:00 committed by GitHub
parent 6e1edb21a5
commit b9799b58f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 244 additions and 45 deletions

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const DEFAULT_REFRESH_INTERVAL_MS = 30000;
export const MINIMUM_REFRESH_INTERVAL_MS = 5000;
export const MINIMUM_REFRESH_INTERVAL_MS = 1000;
export const DELETING_JOBS_REFRESH_INTERVAL_MS = 2000;

View file

@ -7,6 +7,7 @@
export * from './aggregations';
export * from './dropdown';
export * from './kibana_context';
export * from './navigation';
export * from './pivot_aggs';
export * from './pivot_group_by';
export * from './request';

View file

@ -0,0 +1,9 @@
/*
* 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 function moveToDataFrameWizard() {
window.location.href = `#/data_frames/new_job`;
}

View file

@ -15,9 +15,7 @@ import {
createPermissionFailureMessage,
} from '../../../../../privilege/check_privilege';
function newJob() {
window.location.href = `#/data_frames/new_job`;
}
import { moveToDataFrameWizard } from '../../../../common';
export const CreateJobButton: SFC = () => {
const disabled =
@ -26,7 +24,13 @@ export const CreateJobButton: SFC = () => {
!checkPermission('canStartStopDataFrameJob');
const button = (
<EuiButton disabled={disabled} fill onClick={newJob} iconType="plusInCircle" size="s">
<EuiButton
disabled={disabled}
fill
onClick={moveToDataFrameWizard}
iconType="plusInCircle"
size="s"
>
<FormattedMessage
id="xpack.ml.dataframe.jobsList.createDataFrameButton"
defaultMessage="Create data frame"

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data Frame: Job List <DataFrameJobList /> Minimal initialization 1`] = `
<EuiEmptyPrompt
actions={
Array [
<EuiButtonEmpty
color="primary"
iconSide="left"
onClick={[Function]}
type="button"
>
Create your first data frame job
</EuiButtonEmpty>,
]
}
iconColor="subdued"
title={
<h2>
No data frame jobs found
</h2>
}
/>
`;

View file

@ -10,12 +10,13 @@ describe('Data Frame: Job List Columns', () => {
test('getColumns()', () => {
const columns = getColumns(() => {}, [], () => {});
expect(columns).toHaveLength(6);
expect(columns).toHaveLength(7);
expect(columns[0].isExpander).toBeTruthy();
expect(columns[1].name).toBe('ID');
expect(columns[2].name).toBe('Source index');
expect(columns[3].name).toBe('Target index');
expect(columns[4].name).toBe('Status');
expect(columns[5].name).toBe('Actions');
expect(columns[5].name).toBe('Progress');
expect(columns[6].name).toBe('Actions');
});
});

View file

@ -6,7 +6,15 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBadge, EuiButtonIcon, RIGHT_ALIGNMENT } from '@elastic/eui';
import {
EuiBadge,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiProgress,
EuiText,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import { DataFrameJobListColumn, DataFrameJobListRow, JobId } from './common';
import { getActions } from './actions';
@ -81,6 +89,31 @@ export const getColumns = (
return <EuiBadge color={color}>{item.state.task_state}</EuiBadge>;
},
},
{
name: i18n.translate('xpack.ml.dataframe.progress', { defaultMessage: 'Progress' }),
sortable: true,
truncateText: true,
render(item: DataFrameJobListRow) {
let progress = 0;
if (item.state.progress !== undefined) {
progress = Math.round(item.state.progress.percent_complete);
}
return (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem>
<EuiProgress value={progress} max={100} color="primary" size="m">
{progress}%
</EuiProgress>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs">{`${progress}%`}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
},
},
{
name: i18n.translate('xpack.ml.dataframe.tableActionLabel', { defaultMessage: 'Actions' }),
actions,

View file

@ -26,6 +26,11 @@ export interface DataFrameJobState {
// indexer_state is a backend internal attribute
// and should not be considered in the UI.
indexer_state: RunningState;
progress?: {
docs_remaining: number;
percent_complete: number;
total_docs: number;
};
// task_state is the attribute to check against if a job
// is running or not.
task_state: RunningState;

View file

@ -0,0 +1,18 @@
/*
* 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 { shallow } from 'enzyme';
import React from 'react';
import { DataFrameJobList } from './job_list';
describe('Data Frame: Job List <DataFrameJobList />', () => {
test('Minimal initialization', () => {
const wrapper = shallow(<DataFrameJobList />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -4,15 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FunctionComponent, SFC, useEffect, useState } from 'react';
import React, { FunctionComponent, SFC, useState } from 'react';
import {
EuiButtonEmpty,
EuiEmptyPrompt,
EuiInMemoryTable,
EuiInMemoryTableProps,
SortDirection,
} from '@elastic/eui';
import { moveToDataFrameWizard } from '../../../../common';
import {
DataFrameJobListColumn,
DataFrameJobListRow,
@ -22,6 +25,7 @@ import {
import { getJobsFactory } from './job_service';
import { getColumns } from './columns';
import { ExpandedRow } from './expanded_row';
import { useRefreshInterval } from './use_refresh_interval';
function getItemIdToExpandedRowMap(
itemIds: JobId[],
@ -49,17 +53,23 @@ const ExpandableTable = (EuiInMemoryTable as any) as FunctionComponent<Expandabl
export const DataFrameJobList: SFC = () => {
const [dataFrameJobs, setDataFrameJobs] = useState<DataFrameJobListRow[]>([]);
const getJobs = getJobsFactory(setDataFrameJobs);
const [blockRefresh, setBlockRefresh] = useState(false);
const [expandedRowItemIds, setExpandedRowItemIds] = useState<JobId[]>([]);
// use this pattern so we don't return a promise, useEffects doesn't like that
useEffect(() => {
getJobs();
}, []);
const getJobs = getJobsFactory(setDataFrameJobs, blockRefresh);
useRefreshInterval(getJobs, setBlockRefresh);
if (dataFrameJobs.length === 0) {
return <EuiEmptyPrompt title={<h2>Here be Data Frame dragons!</h2>} iconType="editorStrike" />;
return (
<EuiEmptyPrompt
title={<h2>No data frame jobs found</h2>}
actions={[
<EuiButtonEmpty onClick={moveToDataFrameWizard}>
Create your first data frame job
</EuiButtonEmpty>,
]}
/>
);
}
const columns = getColumns(getJobs, expandedRowItemIds, setExpandedRowItemIds);

View file

@ -10,10 +10,12 @@ import { ml } from '../../../../../../services/ml_api_service';
import { DataFrameJobListRow } from '../common';
export const deleteJobFactory = (getJobs: () => void) => async (d: DataFrameJobListRow) => {
import { GetJobs } from './get_jobs';
export const deleteJobFactory = (getJobs: GetJobs) => async (d: DataFrameJobListRow) => {
try {
await ml.dataFrame.deleteDataFrameTransformsJob(d.config.id);
getJobs();
getJobs(true);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.jobsList.deleteJobSuccessMessage', {
defaultMessage: 'Data frame job {jobId} deleted successfully.',

View file

@ -31,31 +31,36 @@ interface GetDataFrameTransformsStatsResponse {
transforms: DataFrameJobStateStats[];
}
export type GetJobs = (forceRefresh?: boolean) => void;
export const getJobsFactory = (
setDataFrameJobs: React.Dispatch<React.SetStateAction<DataFrameJobListRow[]>>
) => async () => {
try {
const jobConfigs: GetDataFrameTransformsResponse = await ml.dataFrame.getDataFrameTransforms();
const jobStats: GetDataFrameTransformsStatsResponse = await ml.dataFrame.getDataFrameTransformsStats();
setDataFrameJobs: React.Dispatch<React.SetStateAction<DataFrameJobListRow[]>>,
blockRefresh: boolean
): GetJobs => async (forceRefresh = false) => {
if (forceRefresh === true || blockRefresh === false) {
try {
const jobConfigs: GetDataFrameTransformsResponse = await ml.dataFrame.getDataFrameTransforms();
const jobStats: GetDataFrameTransformsStatsResponse = await ml.dataFrame.getDataFrameTransformsStats();
const tableRows = jobConfigs.transforms.map(config => {
const stats = jobStats.transforms.find(d => config.id === d.id);
const tableRows = jobConfigs.transforms.map(config => {
const stats = jobStats.transforms.find(d => config.id === d.id);
if (stats === undefined) {
throw new Error('job stats not available');
}
if (stats === undefined) {
throw new Error('job stats not available');
}
// table with expandable rows requires `id` on the outer most level
return { config, id: config.id, state: stats.state, stats: stats.stats };
});
// table with expandable rows requires `id` on the outer most level
return { config, id: config.id, state: stats.state, stats: stats.stats };
});
setDataFrameJobs(tableRows);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobsList.errorGettingDataFrameJobsList', {
defaultMessage: 'An error occurred getting the data frame jobs list: {error}',
values: { error: JSON.stringify(e) },
})
);
setDataFrameJobs(tableRows);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobsList.errorGettingDataFrameJobsList', {
defaultMessage: 'An error occurred getting the data frame jobs list: {error}',
values: { error: JSON.stringify(e) },
})
);
}
}
};

View file

@ -10,7 +10,9 @@ import { ml } from '../../../../../../services/ml_api_service';
import { DataFrameJobListRow } from '../common';
export const startJobFactory = (getJobs: () => void) => async (d: DataFrameJobListRow) => {
import { GetJobs } from './get_jobs';
export const startJobFactory = (getJobs: GetJobs) => async (d: DataFrameJobListRow) => {
try {
await ml.dataFrame.startDataFrameTransformsJob(d.config.id);
toastNotifications.addSuccess(
@ -19,7 +21,7 @@ export const startJobFactory = (getJobs: () => void) => async (d: DataFrameJobLi
values: { jobId: d.config.id },
})
);
getJobs();
getJobs(true);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobsList.startJobErrorMessage', {

View file

@ -10,10 +10,12 @@ import { ml } from '../../../../../../services/ml_api_service';
import { DataFrameJobListRow } from '../common';
export const stopJobFactory = (getJobs: () => void) => async (d: DataFrameJobListRow) => {
import { GetJobs } from './get_jobs';
export const stopJobFactory = (getJobs: GetJobs) => async (d: DataFrameJobListRow) => {
try {
await ml.dataFrame.stopDataFrameTransformsJob(d.config.id);
getJobs();
getJobs(true);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.jobsList.stopJobSuccessMessage', {
defaultMessage: 'Data frame job {jobId} stopped successfully.',

View file

@ -0,0 +1,85 @@
/*
* 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, { useEffect } from 'react';
import { timefilter } from 'ui/timefilter';
import {
DEFAULT_REFRESH_INTERVAL_MS,
MINIMUM_REFRESH_INTERVAL_MS,
} from '../../../../../../common/constants/jobs_list';
import { GetJobs } from './job_service/get_jobs';
export const useRefreshInterval = (
getJobs: GetJobs,
setBlockRefresh: React.Dispatch<React.SetStateAction<boolean>>
) => {
useEffect(() => {
let jobsRefreshInterval: null | number = null;
timefilter.disableTimeRangeSelector();
timefilter.enableAutoRefreshSelector();
initAutoRefresh();
initAutoRefreshUpdate();
function initAutoRefresh() {
const { value } = timefilter.getRefreshInterval();
if (value === 0) {
// the auto refresher starts in an off state
// so switch it on and set the interval to 30s
timefilter.setRefreshInterval({
pause: false,
value: DEFAULT_REFRESH_INTERVAL_MS,
});
}
setAutoRefresh();
}
function initAutoRefreshUpdate() {
// update the interval if it changes
timefilter.on('refreshIntervalUpdate', () => {
setAutoRefresh();
});
}
function setAutoRefresh() {
const { value, pause } = timefilter.getRefreshInterval();
if (pause) {
clearRefreshInterval();
} else {
setRefreshInterval(value);
}
getJobs(true);
}
function setRefreshInterval(interval: number) {
clearRefreshInterval();
if (interval >= MINIMUM_REFRESH_INTERVAL_MS) {
setBlockRefresh(false);
const intervalId = window.setInterval(() => {
getJobs();
}, interval);
jobsRefreshInterval = intervalId;
}
}
function clearRefreshInterval() {
setBlockRefresh(true);
if (jobsRefreshInterval !== null) {
window.clearInterval(jobsRefreshInterval);
}
}
// useEffect cleanup
return () => {
clearRefreshInterval();
};
}, []); // [] as comparator makes sure this only runs once
};

View file

@ -435,4 +435,3 @@ export class JobsListView extends Component {
);
}
}