[ML] Adding filter bar to jobs list (#20415)

* [ML] Adding filter bar to jobs list

* fixing page index when filtering

* refreshing job selection after actions have happened

* adding job counts to groups

* catching multi-select start datafeed errors

* style tweaks

* more style tweaks

* changes based on review

* refactoring search logic
This commit is contained in:
James Gowdy 2018-07-04 13:57:25 +01:00 committed by GitHub
parent 90528194da
commit c4756b183f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 365 additions and 77 deletions

View file

@ -27,6 +27,7 @@ export class JobDetails extends Component {
this.state = {
description: '',
groups: [],
selectedGroups: [],
mml: '',
};

View file

@ -0,0 +1,8 @@
/*
* 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 { JobFilterBar } from './job_filter_bar';

View file

@ -0,0 +1,111 @@
/*
* 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 PropTypes from 'prop-types';
import React, {
Component
} from 'react';
import { ml } from 'plugins/ml/services/ml_api_service';
import { JobGroup } from '../job_group';
import './styles/main.less';
import {
EuiSearchBar,
} from '@elastic/eui';
function loadGroups() {
return ml.jobs.groups()
.then((groups) => {
return groups.map(g => ({
value: g.id,
view: (
<div className="group-item">
<JobGroup name={g.id} /> <span>({g.jobIds.length} job{(g.jobIds.length === 1) ? '' : 's'})</span>
</div>
)
}));
})
.catch((error) => {
console.log(error);
return [];
});
}
export class JobFilterBar extends Component {
constructor(props) {
super(props);
this.setFilters = props.setFilters;
}
onChange = ({ query }) => {
const clauses = query.ast.clauses;
this.setFilters(clauses);
};
render() {
const filters = [
{
type: 'field_value_toggle_group',
field: 'job_state',
items: [
{
value: 'opened',
name: 'Opened'
},
{
value: 'closed',
name: 'Closed'
},
{
value: 'failed',
name: 'Failed'
}
]
},
{
type: 'field_value_toggle_group',
field: 'datafeed_state',
items: [
{
value: 'started',
name: 'Started'
},
{
value: 'stopped',
name: 'Stopped'
}
]
},
{
type: 'field_value_selection',
field: 'groups',
name: 'Group',
multiSelect: 'or',
cache: 10000,
options: () => loadGroups()
}
];
return (
<EuiSearchBar
box={{
incremental: true,
}}
filters={filters}
onChange={this.onChange}
/>
);
}
}
JobFilterBar.propTypes = {
setFilters: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,22 @@
.euiFilterGroup {
max-width: 500px;
.euiPopover .euiPanel {
.group-item {
padding: 6px 12px;
}
.inline-group {
border: 1px solid #FFFFFF;
border-radius: 3px;
}
.euiFilterSelectItem:hover, .euiFilterSelectItem:focus {
text-decoration: none;
.inline-group {
border: 1px solid #555555;
box-shadow: 0px 1px 2px #999;
}
}
}
}

View file

@ -0,0 +1,8 @@
/*
* 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 { JobGroup } from './job_group';

View file

@ -0,0 +1,70 @@
/*
* 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 PropTypes from 'prop-types';
import React from 'react';
import './styles/main.less';
const COLORS = [
'#00B3A4', // euiColorVis0
'#3185FC', // euiColorVis1
'#DB1374', // euiColorVis2
'#490092', // euiColorVis3
// '#FEB6DB', // euiColorVis4 light pink, too hard to read with white text
'#E6C220', // euiColorVis5
'#BFA180', // euiColorVis6
'#F98510', // euiColorVis7
'#461A0A', // euiColorVis8
'#920000', // euiColorVis9
'#666666', // euiColorDarkShade
'#0079A5', // euiColorPrimary
];
const colorMap = {};
export function JobGroup({ name }) {
return (
<div
className="inline-group"
style={{ backgroundColor: tabColor(name) }}
>
{name}
</div>
);
}
JobGroup.propTypes = {
name: PropTypes.string.isRequired,
};
// to ensure the same color is always used for a group name
// the color choice is based on a hash of the group name
function tabColor(name) {
if (colorMap[name] === undefined) {
const n = stringHash(name);
const color = COLORS[(n % COLORS.length)];
colorMap[name] = color;
return color;
} else {
return colorMap[name];
}
}
function stringHash(str) {
let hash = 0;
let chr = '';
if (str.length === 0) {
return hash;
}
for (let i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return hash < 0 ? hash * -2 : hash;
}

View file

@ -0,0 +1,10 @@
.inline-group {
font-size: 12px;
background-color: #D9D9D9;
padding: 2px 5px;
border-radius: 2px;
display: inline-block;
margin: 0px 3px;
color: #FFFFFF;
vertical-align: text-top;
}

View file

@ -8,23 +8,7 @@
import PropTypes from 'prop-types';
import React from 'react';
const COLORS = [
'#00B3A4', // euiColorVis0
'#3185FC', // euiColorVis1
'#DB1374', // euiColorVis2
'#490092', // euiColorVis3
// '#FEB6DB', // euiColorVis4 light pink, too hard to read with white text
'#E6C220', // euiColorVis5
'#BFA180', // euiColorVis6
'#F98510', // euiColorVis7
'#461A0A', // euiColorVis8
'#920000', // euiColorVis9
'#666666', // euiColorDarkShade
'#0079A5', // euiColorPrimary
];
const colorMap = {};
import { JobGroup } from '../job_group';
export function JobDescription({ job }) {
return (
@ -42,44 +26,3 @@ export function JobDescription({ job }) {
JobDescription.propTypes = {
job: PropTypes.object.isRequired,
};
function JobGroup({ name }) {
return (
<div
className="inline-group"
style={{ backgroundColor: tabColor(name) }}
>
{name}
</div>
);
}
JobGroup.propTypes = {
name: PropTypes.string.isRequired,
};
// to ensure the same color is always used for a group name
// the color choice is based on a hash of the group name
function tabColor(name) {
if (colorMap[name] === undefined) {
const n = stringHash(name);
const color = COLORS[(n % COLORS.length)];
colorMap[name] = color;
return color;
} else {
return colorMap[name];
}
}
function stringHash(str) {
let hash = 0;
let chr = '';
if (str.length === 0) {
return hash;
}
for (let i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return hash < 0 ? hash * -2 : hash;
}

View file

@ -76,7 +76,18 @@ export class JobsList extends Component {
list = sortBy(this.state.jobsSummaryList, (item) => item[sortField]);
list = (sortDirection === 'asc') ? list : list.reverse();
const pageStart = (index * size);
let pageStart = (index * size);
if (pageStart >= list.length) {
// if the page start is larger than the number of items
// due to filters being applied, calculate a new page start
pageStart = Math.floor(list.length / size) * size;
// set the state out of the render cycle
setTimeout(() => {
this.setState({
pageIndex: (pageStart / size)
});
}, 0);
}
return {
pageOfItems: list.slice(pageStart, (pageStart + size)),
totalItemCount: list.length,

View file

@ -92,17 +92,6 @@
display: inline-block;
}
.inline-group {
font-size: 12px;
background-color: #D9D9D9;
padding: 2px 5px;
border-radius: 2px;
display: inline-block;
margin: 0px 3px;
color: #FFFFFF;
vertical-align: text-top;
}
.job-loading-spinner {
text-align: center;
}

View file

@ -8,9 +8,10 @@
import './styles/main.less';
import { ml } from 'plugins/ml/services/ml_api_service';
import { loadFullJob } from '../utils';
import { loadFullJob, filterJobs } from '../utils';
import { JobsList } from '../jobs_list';
import { JobDetails } from '../job_details';
import { JobFilterBar } from '../job_filter_bar';
import { EditJobFlyout } from '../edit_job_flyout';
import { DeleteJobModal } from '../delete_job_modal';
import { StartDatafeedModal } from '../start_datafeed_modal';
@ -26,9 +27,11 @@ export class JobsListView extends Component {
this.state = {
jobsSummaryList: [],
filteredJobsSummaryList: [],
fullJobsList: {},
selectedJobs: [],
itemIdToExpandedRowMap: {}
itemIdToExpandedRowMap: {},
filterClauses: []
};
this.updateFunctions = {};
@ -134,6 +137,26 @@ export class JobsListView extends Component {
this.setState({ selectedJobs });
}
refreshSelectedJobs() {
const selectedJobsIds = this.state.selectedJobs.map(j => j.id);
const filteredJobIds = this.state.filteredJobsSummaryList.map(j => j.id);
// refresh the jobs stored as selected
// only select those which are also in the filtered list
const selectedJobs = this.state.jobsSummaryList
.filter(j => selectedJobsIds.find(id => id === j.id))
.filter(j => filteredJobIds.find(id => id === j.id));
this.setState({ selectedJobs });
}
setFilters = (filterClauses) => {
const filteredJobsSummaryList = filterJobs(this.state.jobsSummaryList, filterClauses);
this.setState({ filteredJobsSummaryList, filterClauses }, () => {
this.refreshSelectedJobs();
});
}
refreshJobSummaryList(autoRefresh = true) {
if (this.blockAutoRefresh === false) {
const expandedJobsIds = Object.keys(this.state.itemIdToExpandedRowMap);
@ -148,7 +171,10 @@ export class JobsListView extends Component {
job.latestTimeStampUnix = job.latestTimeStamp.unix;
return job;
});
this.setState({ jobsSummaryList, fullJobsList });
const filteredJobsSummaryList = filterJobs(jobsSummaryList, this.state.filterClauses);
this.setState({ jobsSummaryList, filteredJobsSummaryList, fullJobsList }, () => {
this.refreshSelectedJobs();
});
Object.keys(this.updateFunctions).forEach((j) => {
this.updateFunctions[j].setState({ job: fullJobsList[j] });
@ -176,9 +202,10 @@ export class JobsListView extends Component {
showDeleteJobModal={this.showDeleteJobModal}
refreshJobs={() => this.refreshJobSummaryList(false)}
/>
<JobFilterBar setFilters={this.setFilters} />
</div>
<JobsList
jobsSummaryList={this.state.jobsSummaryList}
jobsSummaryList={this.state.filteredJobsSummaryList}
fullJobsList={this.state.fullJobsList}
itemIdToExpandedRowMap={this.state.itemIdToExpandedRowMap}
toggleRow={this.toggleRow}

View file

@ -1,3 +1,10 @@
.actions-bar {
height: 60px;
display: flex;
& > div:nth-child(1) {
width: 300px;
}
& > div:nth-child(2) {
}
}

View file

@ -1,5 +1,5 @@
.multi-select-actions {
padding: 20px 0px;
padding: 10px 0px;
display: inline-block;
.jobs-selected-title {

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { each } from 'lodash';
import { toastNotifications } from 'ui/notify';
import { mlJobService } from 'plugins/ml/services/job_service';
@ -136,3 +137,77 @@ export function deleteJobs(jobs, finish) {
}
});
}
export function filterJobs(jobs, clauses) {
if (clauses.length === 0) {
return jobs;
}
// keep count of the number of matches we make as we're looping over the clauses
// we only want to return jobs which match all clauses, i.e. each search term is ANDed
const matches = jobs.reduce((p, 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 = jobs.filter(job => ((
(stringMatch(job.id, c.value) === bool) ||
(stringMatch(job.description, c.value) === bool) ||
(stringMatch(job.memory_status, c.value) === bool)
)));
} else {
js = jobs.filter(job => ((
(stringMatch(job.id, c.value) === bool) &&
(stringMatch(job.description, c.value) === bool) &&
(stringMatch(job.memory_status, c.value) === bool)
)));
}
} else {
// filter other clauses, i.e. the toggle group buttons
if (Array.isArray(c.value)) {
// the groups value is an array of group ids
js = jobs.filter(job => (jobProperty(job, c.field).some(g => (c.value.indexOf(g) >= 0))));
} else {
js = jobs.filter(job => (jobProperty(job, c.field) === c.value));
}
}
js.forEach(j => (matches[j.id].count++));
});
// loop through the matches and return only those jobs which have match all the clauses
const filteredJobs = [];
each(matches, (m) => {
if (m.count >= clauses.length) {
filteredJobs.push(m.job);
}
});
return filteredJobs;
}
function stringMatch(str, substr) {
return ((str.toLowerCase().match(substr.toLowerCase()) === null) === false);
}
function jobProperty(job, prop) {
const propMap = {
job_state: 'jobState',
datafeed_state: 'datafeedState',
groups: 'groups',
};
return job[propMap[prop]];
}

View file

@ -43,8 +43,12 @@ export function datafeedsProvider(callWithRequest) {
results[datafeedId] = await doStart(datafeedId);
}, START_TIMEOUT);
if (await openJob(jobId)) {
results[datafeedId] = await doStart(datafeedId);
try {
if (await openJob(jobId)) {
results[datafeedId] = await doStart(datafeedId);
}
} catch (error) {
results[datafeedId] = { started: false, error };
}
}
@ -59,6 +63,8 @@ export function datafeedsProvider(callWithRequest) {
} catch (error) {
if (error.statusCode === 409) {
opened = true;
} else {
throw error;
}
}
return opened;