Add targets to packs (#831)

* Allow form field values to be an array

* Send the server host and label ids on create

* Get and display the targets in a pack

* Adds target_type to labels and hosts

* Allow updating a pack’s targets as well as name and description

* Adds select targets dropdown to edit pack page

* Adds targets to dropdown when pack is edited
This commit is contained in:
Mike Stone 2017-01-11 12:10:14 -05:00 committed by GitHub
parent c2084026d1
commit d1a18bcb89
14 changed files with 184 additions and 50 deletions

View file

@ -4,8 +4,9 @@ import Button from 'components/buttons/Button';
import Form from 'components/forms/Form';
import formFieldInterface from 'interfaces/form_field';
import InputField from 'components/forms/fields/InputField';
import SelectTargetsDropdown from 'components/forms/fields/SelectTargetsDropdown';
const fieldNames = ['description', 'name'];
const fieldNames = ['description', 'name', 'targets'];
class EditPackForm extends Component {
static propTypes = {
@ -13,13 +14,23 @@ class EditPackForm extends Component {
fields: PropTypes.shape({
description: formFieldInterface.isRequired,
name: formFieldInterface.isRequired,
targets: formFieldInterface.isRequired,
}).isRequired,
handleSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onFetchTargets: PropTypes.func,
targetsCount: PropTypes.number,
};
render () {
const { className, fields, handleSubmit, onCancel } = this.props;
const {
className,
fields,
handleSubmit,
onCancel,
onFetchTargets,
targetsCount,
} = this.props;
return (
<form className={className} onSubmit={handleSubmit}>
@ -29,6 +40,15 @@ class EditPackForm extends Component {
<InputField
{...fields.description}
/>
<SelectTargetsDropdown
{...fields.targets}
label="select pack targets"
name="selected-pack-targets"
onFetchTargets={onFetchTargets}
onSelect={fields.targets.onChange}
selectedTargets={fields.targets.value}
targetsCount={targetsCount}
/>
<Button onClick={onCancel} type="button" variant="inverse">CANCEL</Button>
<Button type="submit" variant="brand">SAVE</Button>
</form>

View file

@ -6,6 +6,7 @@ import EditPackForm from 'components/forms/packs/EditPackForm';
import Icon from 'components/icons/Icon';
import packInterface from 'interfaces/pack';
import SelectTargetsDropdown from 'components/forms/fields/SelectTargetsDropdown';
import targetInterface from 'interfaces/target';
const baseClass = 'edit-pack-form';
@ -18,6 +19,7 @@ class EditPackFormWrapper extends Component {
onEditPack: PropTypes.func.isRequired,
onFetchTargets: PropTypes.func,
pack: packInterface.isRequired,
packTargets: PropTypes.arrayOf(targetInterface),
targetsCount: PropTypes.number,
};
@ -30,6 +32,7 @@ class EditPackFormWrapper extends Component {
onEditPack,
onFetchTargets,
pack,
packTargets,
targetsCount,
} = this.props;
@ -37,9 +40,11 @@ class EditPackFormWrapper extends Component {
return (
<EditPackForm
className={className}
formData={pack}
formData={{ ...pack, targets: packTargets }}
handleSubmit={handleSubmit}
onCancel={onCancelEditPack}
onFetchTargets={onFetchTargets}
targetsCount={targetsCount}
/>
);
}
@ -63,6 +68,7 @@ class EditPackFormWrapper extends Component {
name="selected-pack-targets"
onFetchTargets={onFetchTargets}
onSelect={noop}
selectedTargets={packTargets}
targetsCount={targetsCount}
disabled
className={`${baseClass}__select-targets`}

View file

@ -4,6 +4,8 @@ export default PropTypes.shape({
error: PropTypes.string,
name: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
value: PropTypes.oneOfType(
[PropTypes.array, PropTypes.bool, PropTypes.number, PropTypes.string]
),
});

View file

@ -1,8 +1,5 @@
import { PropTypes } from 'react';
import hostInterface from 'interfaces/host';
import labelInterface from 'interfaces/label';
export default PropTypes.shape({
id: PropTypes.number,
label: PropTypes.string,
name: PropTypes.string,
target_type: PropTypes.string,
});
export default PropTypes.oneOfType([hostInterface, labelInterface]);

View file

@ -47,11 +47,15 @@ export const formatConfigDataForServer = (config) => {
};
};
export const formatSelectedTargetsForApi = (selectedTargets) => {
export const formatSelectedTargetsForApi = (selectedTargets, appendID = false) => {
const targets = selectedTargets || [];
const hosts = flatMap(targets, filterTarget('hosts'));
const labels = flatMap(targets, filterTarget('labels'));
if (appendID) {
return { host_ids: hosts, label_ids: labels };
}
return { hosts, labels };
};

View file

@ -53,6 +53,15 @@ describe('Kolide API - helpers', () => {
labels: [1, 2],
});
});
it('appends `_id` when appendID is specified', () => {
const targets = [host1, host2, label1, label2];
expect(formatSelectedTargetsForApi(targets, true)).toEqual({
host_ids: [6, 5],
label_ids: [1, 2],
});
});
});
describe('#setupData', () => {

View file

@ -32,10 +32,11 @@ class Kolide extends Base {
});
}
createPack = ({ name, description }) => {
createPack = ({ name, description, targets }) => {
const { PACKS } = endpoints;
const packTargets = helpers.formatSelectedTargetsForApi(targets, true);
return this.authenticatedPost(this.endpoint(PACKS), JSON.stringify({ description, name }))
return this.authenticatedPost(this.endpoint(PACKS), JSON.stringify({ description, name, ...packTargets }))
.then((response) => { return response.pack; });
}
@ -121,7 +122,7 @@ class Kolide extends Base {
const { HOSTS } = endpoints;
return this.authenticatedGet(this.endpoint(HOSTS))
.then((response) => { return response.hosts; });
.then(response => response.hosts);
}
getLabelHosts = (labelID) => {
@ -358,11 +359,12 @@ class Kolide extends Base {
return this.authenticatedPatch(this.endpoint(CONFIG), JSON.stringify(configData));
}
updatePack = ({ id: packID }, updateParams) => {
updatePack = (pack, { description, name, targets }) => {
const { PACKS } = endpoints;
const updatePackEndpoint = `${this.baseURL}${PACKS}/${packID}`;
const updatePackEndpoint = `${this.baseURL}${PACKS}/${pack.id}`;
const packTargets = helpers.formatSelectedTargetsForApi(targets, true);
return this.authenticatedPatch(updatePackEndpoint, JSON.stringify(updateParams))
return this.authenticatedPatch(updatePackEndpoint, JSON.stringify({ description, name, ...packTargets }))
.then((response) => { return response.pack; });
}

View file

@ -4,7 +4,7 @@ import nock from 'nock';
import Kolide from 'kolide';
import helpers from 'kolide/helpers';
import mocks from 'test/mocks';
import { queryStub, userStub } from 'test/stubs';
import { hostStub, queryStub, userStub } from 'test/stubs';
const {
invalidForgotPasswordRequest,
@ -80,7 +80,7 @@ describe('Kolide - API client', () => {
it('#createPack', (done) => {
const { description, name } = pack;
const params = { description, name };
const params = { description, name, host_ids: [], label_ids: [] };
const request = validCreatePackRequest(bearerToken, params);
Kolide.setBearerToken(bearerToken);
@ -105,7 +105,9 @@ describe('Kolide - API client', () => {
});
it('#updatePack', (done) => {
const updatePackParams = { name: 'New Pack Name' };
const targets = [hostStub];
const packTargets = helpers.formatSelectedTargetsForApi(targets, true);
const updatePackParams = { name: 'New Pack Name', ...packTargets };
const request = validUpdatePackRequest(bearerToken, pack, updatePackParams);
Kolide.setBearerToken(bearerToken);

View file

@ -1,9 +1,13 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { noop, size, find } from 'lodash';
import { filter, includes, isEqual, noop, size, find } from 'lodash';
import { push } from 'react-router-redux';
import EditPackFormWrapper from 'components/packs/EditPackFormWrapper';
import hostActions from 'redux/nodes/entities/hosts/actions';
import hostInterface from 'interfaces/host';
import labelActions from 'redux/nodes/entities/labels/actions';
import labelInterface from 'interfaces/label';
import packActions from 'redux/nodes/entities/packs/actions';
import ScheduleQuerySidePanel from 'components/side_panels/ScheduleQuerySidePanel';
import packInterface from 'interfaces/pack';
@ -24,7 +28,9 @@ export class EditPackPage extends Component {
isLoadingPack: PropTypes.bool,
isLoadingScheduledQueries: PropTypes.bool,
pack: packInterface,
packHosts: PropTypes.arrayOf(hostInterface),
packID: PropTypes.string,
packLabels: PropTypes.arrayOf(labelInterface),
scheduledQueries: PropTypes.arrayOf(queryInterface),
};
@ -41,7 +47,16 @@ export class EditPackPage extends Component {
}
componentDidMount () {
const { allQueries, dispatch, isLoadingPack, pack, packID, scheduledQueries } = this.props;
const {
allQueries,
dispatch,
isLoadingPack,
pack,
packHosts,
packID,
packLabels,
scheduledQueries,
} = this.props;
const { load } = packActions;
const { loadAll } = queryActions;
@ -49,6 +64,16 @@ export class EditPackPage extends Component {
dispatch(load(packID));
}
if (pack) {
if (!packHosts || packHosts.length !== pack.host_ids.length) {
dispatch(hostActions.loadAll());
}
if (!packLabels || packLabels.length !== pack.label_ids.length) {
dispatch(labelActions.loadAll());
}
}
if (!size(scheduledQueries)) {
dispatch(scheduledQueryActions.loadAll({ id: packID }));
}
@ -60,6 +85,20 @@ export class EditPackPage extends Component {
return false;
}
componentWillReceiveProps ({ dispatch, pack, packHosts, packLabels }) {
if (!isEqual(pack, this.props.pack)) {
if (!packHosts || packHosts.length !== pack.host_ids.length) {
dispatch(hostActions.loadAll());
}
if (!packLabels || packLabels.length !== pack.label_ids.length) {
dispatch(labelActions.loadAll());
}
}
return false;
}
onCancelEditPack = () => {
const { dispatch, isEdit, packID } = this.props;
@ -97,10 +136,10 @@ export class EditPackPage extends Component {
}
handlePackFormSubmit = (formData) => {
const { dispatch } = this.props;
const { dispatch, pack } = this.props;
const { update } = packActions;
return dispatch(update(formData));
return dispatch(update(pack, formData));
}
handleRemoveScheduledQueries = (scheduledQueryIDs) => {
@ -149,7 +188,9 @@ export class EditPackPage extends Component {
onToggleEdit,
} = this;
const { targetsCount, selectedQuery } = this.state;
const { allQueries, isEdit, isLoadingScheduledQueries, pack, scheduledQueries } = this.props;
const { allQueries, isEdit, isLoadingScheduledQueries, pack, packHosts, packLabels, scheduledQueries } = this.props;
const packTargets = [...packHosts, ...packLabels];
if (!pack || isLoadingScheduledQueries) {
return false;
@ -166,6 +207,7 @@ export class EditPackPage extends Component {
onEditPack={onToggleEdit}
onFetchTargets={onFetchTargets}
pack={pack}
packTargets={packTargets}
targetsCount={targetsCount}
/>
<ScheduledQueriesListWrapper
@ -194,6 +236,12 @@ const mapStateToProps = (state, { params, route }) => {
const scheduledQueries = entityGetter.get('scheduled_queries').where({ pack_id: packID });
const isLoadingScheduledQueries = state.entities.scheduled_queries.loading;
const isEdit = route.path === 'edit';
const packHosts = pack ? filter(state.entities.hosts.data, (host) => {
return includes(pack.host_ids, host.id);
}) : [];
const packLabels = pack ? filter(state.entities.labels.data, (label) => {
return includes(pack.label_ids, label.id);
}) : [];
return {
allQueries,
@ -201,7 +249,9 @@ const mapStateToProps = (state, { params, route }) => {
isLoadingPack,
isLoadingScheduledQueries,
pack,
packHosts,
packID,
packLabels,
scheduledQueries,
};
};

View file

@ -8,6 +8,8 @@ import EditPackPage from './EditPackPage';
describe('EditPackPage - component', () => {
const store = {
entities: {
hosts: { data: {} },
labels: { data: {} },
packs: {
data: {
[packStub.id]: packStub,

View file

@ -3,7 +3,6 @@ import { connect } from 'react-redux';
import { noop } from 'lodash';
import { push } from 'react-router-redux';
import Kolide from 'kolide';
import packActions from 'redux/nodes/entities/packs/actions';
import PackForm from 'components/forms/packs/PackForm';
import PackInfoSidePanel from 'components/side_panels/PackInfoSidePanel';
@ -47,37 +46,15 @@ export class PackComposerPage extends Component {
}
handleSubmit = (formData) => {
const { create, load } = packActions;
const { create } = packActions;
const { dispatch } = this.props;
const { visitPackPage } = this;
return dispatch(create(formData))
.then((pack) => {
const { id: packID } = pack;
const { targets } = formData;
if (!targets) {
return visitPackPage(packID);
}
const promises = targets.map((target) => {
const { id: targetID } = target;
if (target.target_type === 'labels') {
Kolide.addLabelToPack(packID, targetID);
}
// TODO: Add host to pack when API is available
return Promise.resolve();
});
return Promise.all(promises)
.then(() => {
dispatch(load(packID))
.then(() => {
return visitPackPage(packID);
});
});
return visitPackPage(packID);
});
}

View file

@ -7,5 +7,8 @@ const { HOSTS: schema } = schemas;
export default reduxConfig({
entityName: 'hosts',
loadAllFunc: Kolide.getHosts,
parseEntityFunc: (host) => {
return { ...host, target_type: 'hosts' };
},
schema,
});

View file

@ -8,5 +8,8 @@ export default reduxConfig({
createFunc: Kolide.createLabel,
entityName: 'labels',
loadAllFunc: Kolide.getLabels,
parseEntityFunc: (label) => {
return { ...label, target_type: 'labels' };
},
schema,
});

View file

@ -30,6 +30,60 @@ export const configStub = {
},
};
export const hostStub = {
created_at: '2017-01-10T19:18:55Z',
updated_at: '2017-01-10T20:13:52Z',
deleted_at: null,
deleted: false,
id: 1,
detail_updated_at: '2017-01-10T20:01:48Z',
seen_time: '2017-01-10T20:13:54Z',
hostname: '52883a0ba916',
uuid: 'FD87130B-09A9-683D-9095-D92CD20728CA',
platform: 'ubuntu',
osquery_version: '2.1.2',
os_version: 'Ubuntu 14.4.',
build: '',
platform_like: 'debian',
code_name: '',
uptime: 45469000000000,
memory: 2094940160,
cpu_type: '6',
cpu_subtype: '78',
cpu_brand: 'Intel(R) Core(TM) i5-6267U CPU @ 2.90GHz',
cpu_physical_cores: 2,
cpu_logical_cores: 2,
hardware_vendor: ' ',
hardware_model: 'BHYVE',
hardware_version: '1.0',
hardware_serial: 'None',
computer_name: '52883a0ba916',
primary_ip_id: 1,
network_interfaces: [
{
id: 1,
interface: 'eth0',
address: '172.19.0.8',
mask: '255.255.0.0',
broadcast: '172.19.0.8',
point_to_point: '',
mac: '02:42:ac:13:00:08',
type: 1,
mtu: 1500,
metric: 0,
ipackets: 512,
opackets: 317,
ibytes: 97231,
obytes: 43502,
ierrors: 0,
oerrors: 0,
last_change: -1,
},
],
status: 'online',
display_text: '52883a0ba916',
};
export const packStub = {
created_at: '0001-01-01T00:00:00Z',
updated_at: '0001-01-01T00:00:00Z',
@ -41,6 +95,8 @@ export const packStub = {
platform: '',
created_by: 1,
disabled: false,
host_ids: [],
label_ids: [],
};
export const queryStub = {
@ -82,6 +138,7 @@ export const userStub = {
export default {
adminUserStub,
configStub,
hostStub,
packStub,
queryStub,
scheduledQueryStub,