Skip to content

Commit

Permalink
[ML] Adding group selector to jobs management (#21780) (#21814)
Browse files Browse the repository at this point in the history
* [ML] [WIP] Adding group selector to jobs management

* adding group name validation

* removing comment

* adding keyboard events

* moving new group input to its own component

* changes based on review

* adding tooltip

* adding better error reporting
  • Loading branch information
jgowdyelastic authored Aug 9, 2018
1 parent fef8355 commit af1904c
Show file tree
Hide file tree
Showing 15 changed files with 559 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
import { JobDetails, Detectors, Datafeed, CustomUrls } from './tabs';
import { saveJob } from './edit_utils';
import { loadFullJob } from '../utils';
import { validateModelMemoryLimit, validateGroupNames } from './validate_job';
import { validateModelMemoryLimit, validateGroupNames } from '../validate_job';
import { mlMessageBarService } from 'plugins/ml/components/messagebar/messagebar_service';
import { toastNotifications } from 'ui/notify';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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 {
EuiIcon,
} from '@elastic/eui';

import './styles/main.less';

import { JobGroup } from '../../../job_group';

function Check({ group, selectedGroups }) {
if (selectedGroups[group.id] !== undefined) {
if (selectedGroups[group.id].partial) {
return (
<div className="check selected">
<span>&mdash;</span>
</div>
);
} else {
return (
<div className="check selected">
<EuiIcon type="check" />
</div>
);
}
} else {
return (
<div className="check" />
);
}
}

export class GroupList extends Component {
constructor(props) {
super(props);

this.state = {
groups: [],
};
}

selectGroup = (group) => {
this.props.selectGroup(group);
}

render() {
const { selectedGroups, groups } = this.props;
return (
<div className="group-list">
{
groups.map(g => (
<div
key={g.id}
className="group-item"
onClick={() => this.selectGroup(g)}
>
<Check group={g} selectedGroups={selectedGroups} />
<JobGroup name={g.id} />
</div>
))
}
</div>
);
}
}
GroupList.propTypes = {
selectedGroups: PropTypes.object.isRequired,
groups: PropTypes.array.isRequired,
selectGroup: PropTypes.func.isRequired,
};
Original file line number Diff line number Diff line change
@@ -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 { GroupList } from './group_list';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.group-list {
max-height: 350px;
overflow: auto;

.group-item {
line-height: 18px;
padding: 6px 0px;
border-bottom: 1px solid #eee;
cursor: pointer;

.check {
width: 20px;
display: inline-block;
}

.inline-group {
border: 1px solid #FFFFFF;
border-radius: 3px;
}
}
.group-item:hover {
.inline-group {
border: 1px solid #555555;
box-shadow: 0px 1px 2px #999;
}
}

.group-item:last-child {
margin-bottom: 0px;
border-bottom: none;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/*
* 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 {
EuiButton,
EuiToolTip,
EuiPopover,
EuiPopoverTitle,
EuiButtonIcon,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';

import { cloneDeep } from 'lodash';

import './styles/main.less';
import { ml } from '../../../../../services/ml_api_service';
import { GroupList } from './group_list';
import { NewGroupInput } from './new_group_input';
import { mlMessageBarService } from '../../../../../components/messagebar/messagebar_service';

function createSelectedGroups(jobs, groups) {
const jobIds = jobs.map(j => j.id);
const groupCounts = {};
jobs.forEach((j) => {
j.groups.forEach((g) => {
if (groupCounts[g] === undefined) {
groupCounts[g] = 0;
}
groupCounts[g]++;
});
});

const selectedGroups = groups.reduce((p, c) => {
if (c.jobIds.some(j => jobIds.includes(j))) {
p[c.id] = {
partial: (groupCounts[c.id] !== jobIds.length),
};
}
return p;
}, {});

return selectedGroups;
}

export class GroupSelector extends Component {
constructor(props) {
super(props);

this.state = {
isPopoverOpen: false,
groups: [],
selectedGroups: {},
edited: false,
};

this.refreshJobs = this.props.refreshJobs;
}

static getDerivedStateFromProps(props, state) {
if (state.edited === false) {
const selectedGroups = createSelectedGroups(props.jobs, state.groups);
return { selectedGroups };
} else {
return {};
}
}

togglePopover = () => {
if (this.state.isPopoverOpen) {
this.closePopover();
} else {
ml.jobs.groups()
.then((groups) => {
const selectedGroups = createSelectedGroups(this.props.jobs, groups);

this.setState({
isPopoverOpen: true,
edited: false,
selectedGroups,
groups,
});
})
.catch((error) => {
console.error(error);
});
}
}

closePopover = () => {
this.setState({
edited: false,
isPopoverOpen: false,
});
}

selectGroup = (group) => {
const newSelectedGroups = cloneDeep(this.state.selectedGroups);

if (newSelectedGroups[group.id] === undefined) {
newSelectedGroups[group.id] = {
partial: false,
};
} else if (newSelectedGroups[group.id].partial === true) {
newSelectedGroups[group.id].partial = false;
} else {
delete newSelectedGroups[group.id];
}

this.setState({
selectedGroups: newSelectedGroups,
edited: true,
});
}

applyChanges = () => {
const { selectedGroups } = this.state;
const { jobs } = this.props;
const newJobs = jobs.map(j => ({
id: j.id,
oldGroups: j.groups,
newGroups: [],
}));

for (const gId in selectedGroups) {
if (selectedGroups.hasOwnProperty(gId)) {
const group = selectedGroups[gId];
newJobs.forEach((j) => {
if (group.partial === false || (group.partial === true && j.oldGroups.includes(gId))) {
j.newGroups.push(gId);
}
});
}
}

const tempJobs = newJobs.map(j => ({ job_id: j.id, groups: j.newGroups }));
ml.jobs.updateGroups(tempJobs)
.then((resp) => {
let success = true;
for (const jobId in resp) {
// check success of each job update
if (resp.hasOwnProperty(jobId)) {
if (resp[jobId].success === false) {
mlMessageBarService.notify.error(resp[jobId].error);
success = false;
}
}
}

if (success) {
// if all are successful refresh the job list
this.refreshJobs();
this.closePopover();
} else {
console.error(resp);
}
})
.catch((error) => {
mlMessageBarService.notify.error(error);
console.error(error);
});
}

addNewGroup = (id) => {
const newGroup = {
id,
calendarIds: [],
jobIds: [],
};

const groups = this.state.groups;
if (groups.some(g => g.id === newGroup.id) === false) {
groups.push(newGroup);
}

this.setState({
groups,
});
}

render() {
const {
groups,
selectedGroups,
edited,
} = this.state;
const button = (
<EuiToolTip
position="bottom"
content={`Edit job groups`}
>
<EuiButtonIcon
iconType="indexEdit"
aria-label="Edit job groups"
onClick={() => this.togglePopover()}
/>
</EuiToolTip>
);
const s = (this.props.jobs.length > 1 ? 's' : '');

return (
<EuiPopover
id="trapFocus"
ownFocus
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={() => this.closePopover()}
>
<div className="group-selector">
<EuiPopoverTitle>Apply groups to job{s}</EuiPopoverTitle>

<GroupList
groups={groups}
selectedGroups={selectedGroups}
selectGroup={this.selectGroup}
/>

<EuiHorizontalRule margin="xs" />
<EuiSpacer size="s"/>

<NewGroupInput
addNewGroup={this.addNewGroup}
/>

<EuiHorizontalRule margin="m" />
<div>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
onClick={this.applyChanges}
isDisabled={(edited === false)}
>
Apply
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</EuiPopover>
);
}
}
GroupSelector.propTypes = {
jobs: PropTypes.array.isRequired,
refreshJobs: PropTypes.func.isRequired,
};
Original file line number Diff line number Diff line change
@@ -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 { GroupSelector } from './group_selector';
Loading

0 comments on commit af1904c

Please sign in to comment.