From d65ae02f64f04a3fea60ca643cb76fe14b116fb1 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 4 Jul 2018 13:57:25 +0100 Subject: [PATCH] [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 --- .../edit_job_flyout/tabs/job_details.js | 1 + .../components/job_filter_bar/index.js | 8 ++ .../job_filter_bar/job_filter_bar.js | 111 ++++++++++++++++++ .../job_filter_bar/styles/main.less | 22 ++++ .../components/job_group/index.js | 8 ++ .../components/job_group/job_group.js | 70 +++++++++++ .../components/job_group/styles/main.less | 10 ++ .../components/jobs_list/job_description.js | 59 +--------- .../components/jobs_list/jobs_list.js | 13 +- .../components/jobs_list/styles/main.less | 11 -- .../jobs_list_view/jobs_list_view.js | 35 +++++- .../jobs_list_view/styles/main.less | 7 ++ .../multi_job_actions/styles/main.less | 2 +- .../jobs/jobs_list_new/components/utils.js | 75 ++++++++++++ .../ml/server/models/job_service/datafeeds.js | 10 +- 15 files changed, 365 insertions(+), 77 deletions(-) create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/index.js create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/job_filter_bar.js create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/styles/main.less create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/index.js create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/job_group.js create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/styles/main.less diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/edit_job_flyout/tabs/job_details.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/edit_job_flyout/tabs/job_details.js index 779553ef43e4d..876f70994a85a 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/edit_job_flyout/tabs/job_details.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/edit_job_flyout/tabs/job_details.js @@ -27,6 +27,7 @@ export class JobDetails extends Component { this.state = { description: '', + groups: [], selectedGroups: [], mml: '', }; diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/index.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/index.js new file mode 100644 index 0000000000000..dbf62a8b55162 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/index.js @@ -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'; diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/job_filter_bar.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/job_filter_bar.js new file mode 100644 index 0000000000000..297a27c869384 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/job_filter_bar.js @@ -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: ( +
+ ({g.jobIds.length} job{(g.jobIds.length === 1) ? '' : 's'}) +
+ ) + })); + }) + .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 ( + + ); + } +} +JobFilterBar.propTypes = { + setFilters: PropTypes.func.isRequired, +}; + diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/styles/main.less b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/styles/main.less new file mode 100644 index 0000000000000..18259dfd675f1 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_filter_bar/styles/main.less @@ -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; + } + } + } +} diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/index.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/index.js new file mode 100644 index 0000000000000..3b53348dd41f9 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/index.js @@ -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'; diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/job_group.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/job_group.js new file mode 100644 index 0000000000000..cf60ea1be5791 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/job_group.js @@ -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 ( +
+ {name} +
+ ); +} +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; +} diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/styles/main.less b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/styles/main.less new file mode 100644 index 0000000000000..333dd78a266c2 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_group/styles/main.less @@ -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; +} diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/job_description.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/job_description.js index 3737c337b6bf2..58384bedffdb5 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/job_description.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/job_description.js @@ -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 ( -
- {name} -
- ); -} -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; -} diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/jobs_list.js index 88927adfdcc38..cd547feee49a5 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/jobs_list.js @@ -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, diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/styles/main.less b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/styles/main.less index 2806197456c3f..3a83fa66e0c92 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/styles/main.less +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list/styles/main.less @@ -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; } diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list_view/jobs_list_view.js index ef9c178018e1a..d413604eae75e 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list_view/jobs_list_view.js @@ -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)} /> + div:nth-child(1) { + width: 300px; + } + & > div:nth-child(2) { + } } diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/multi_job_actions/styles/main.less b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/multi_job_actions/styles/main.less index 4a0ed99bcf551..7cbabafadf84f 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/multi_job_actions/styles/main.less +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/multi_job_actions/styles/main.less @@ -1,5 +1,5 @@ .multi-select-actions { - padding: 20px 0px; + padding: 10px 0px; display: inline-block; .jobs-selected-title { diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/utils.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/utils.js index 67c2416a4f439..e90d42be18651 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/utils.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/utils.js @@ -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]]; +} + diff --git a/x-pack/plugins/ml/server/models/job_service/datafeeds.js b/x-pack/plugins/ml/server/models/job_service/datafeeds.js index e1ee4f1955bc2..7caa8ff1a3175 100644 --- a/x-pack/plugins/ml/server/models/job_service/datafeeds.js +++ b/x-pack/plugins/ml/server/models/job_service/datafeeds.js @@ -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;