mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
90528194da
commit
c4756b183f
15 changed files with 365 additions and 77 deletions
|
@ -27,6 +27,7 @@ export class JobDetails extends Component {
|
|||
|
||||
this.state = {
|
||||
description: '',
|
||||
groups: [],
|
||||
selectedGroups: [],
|
||||
mml: '',
|
||||
};
|
||||
|
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
.actions-bar {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
|
||||
& > div:nth-child(1) {
|
||||
width: 300px;
|
||||
}
|
||||
& > div:nth-child(2) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.multi-select-actions {
|
||||
padding: 20px 0px;
|
||||
padding: 10px 0px;
|
||||
display: inline-block;
|
||||
|
||||
.jobs-selected-title {
|
||||
|
|
|
@ -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]];
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue