Allow users to delete hosts (#1028)

This commit is contained in:
Mike Stone 2017-01-19 18:39:06 -05:00 committed by Jason Meller
parent 28130b529b
commit 3c6b59197d
12 changed files with 225 additions and 69 deletions

View File

@ -1,5 +1,4 @@
import React, { PropTypes } from 'react';
import { noop } from 'lodash';
import Button from 'components/buttons/Button';
import hostInterface from 'interfaces/host';
@ -14,7 +13,7 @@ export const STATUSES = {
offline: 'OFFLINE',
};
const HostDetails = ({ host, onQueryClick = noop }) => {
const HostDetails = ({ host, onDestroyHost }) => {
const {
hostname,
ip,
@ -28,9 +27,9 @@ const HostDetails = ({ host, onQueryClick = noop }) => {
return (
<div className={`${baseClass} ${baseClass}--${status}`}>
<span className={`${baseClass}__add-query`}>
<Button onClick={onQueryClick(host)} variant="unstyled" title="Query this host">
<Icon name="query" className={`${baseClass}__add-query-icon`} key="add-query" />
<span className={`${baseClass}__delete-host`}>
<Button onClick={onDestroyHost(host)} variant="unstyled" title="Delete this host">
<Icon name="trash" className={`${baseClass}__delete-host-icon`} />
</Button>
</span>
@ -80,7 +79,7 @@ const HostDetails = ({ host, onQueryClick = noop }) => {
HostDetails.propTypes = {
host: hostInterface.isRequired,
onQueryClick: PropTypes.func,
onDestroyHost: PropTypes.func.isRequired,
};
export default HostDetails;

View File

@ -0,0 +1,21 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import { hostStub } from 'test/stubs';
import HostDetails from 'components/hosts/HostDetails';
describe('HostDetails - component', () => {
afterEach(restoreSpies);
it('calls the onDestroyHost prop when the trash icon button is clicked', () => {
const spy = createSpy();
const component = mount(<HostDetails host={hostStub} onDestroyHost={spy} />);
const btn = component.find('Button');
btn.simulate('click');
expect(spy).toHaveBeenCalled();
});
});

View File

@ -44,7 +44,7 @@
margin: 0;
}
&__add-query {
&__delete-host {
float: right;
span {
@ -55,7 +55,7 @@
}
}
&__add-query-icon {
&__delete-host-icon {
color: $link;
font-size: 20px;
}

View File

@ -1,6 +1,7 @@
import React, { Component, PropTypes } from 'react';
import classnames from 'classnames';
import Button from 'components/buttons/Button';
import Icon from 'components/icons/Icon';
import PlatformIcon from 'components/icons/PlatformIcon';
import hostInterface from 'interfaces/host';
@ -11,9 +12,11 @@ const baseClass = 'hosts-table';
class HostsTable extends Component {
static propTypes = {
hosts: PropTypes.arrayOf(hostInterface),
onDestroyHost: PropTypes.func,
};
renderHost = (host) => {
const { onDestroyHost } = this.props;
const statusClassName = classnames(`${baseClass}__status`, `${baseClass}__status--${host.status}`);
return (
@ -24,7 +27,7 @@ class HostsTable extends Component {
<td>{host.osquery_version}</td>
<td>{host.ip}</td>
<td>{host.mac}</td>
<td><a href="#add-query"><Icon name="add-plus" /></a></td>
<td><Button onClick={onDestroyHost(host)} variant="unstyled"><Icon name="trash" /></Button></td>
</tr>
);
}
@ -44,7 +47,7 @@ class HostsTable extends Component {
<th>Osquery</th>
<th>IPv4</th>
<th>Physical Address</th>
<th><Icon name="query" /></th>
<th />
</tr>
</thead>
<tbody>

View File

@ -0,0 +1,20 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import { hostStub } from 'test/stubs';
import HostsTable from 'components/hosts/HostsTable';
describe('HostsTable - component', () => {
afterEach(restoreSpies);
it('calls the onDestroyHost prop when the trash icon button is clicked', () => {
const spy = createSpy();
const component = mount(<HostsTable hosts={[hostStub]} onDestroyHost={spy} />);
const btn = component.find('Button');
btn.simulate('click');
expect(spy).toHaveBeenCalled();
});
});

View File

@ -35,6 +35,21 @@ class Kolide extends Base {
},
}
hosts = {
loadAll: () => {
const { HOSTS } = endpoints;
return this.authenticatedGet(this.endpoint(HOSTS))
.then(response => response.hosts);
},
destroy: (host) => {
const { HOSTS } = endpoints;
const endpoint = this.endpoint(`${HOSTS}/${host.id}`);
return this.authenticatedDelete(endpoint);
},
}
statusLabels = {
getCounts: () => {
const { STATUS_LABEL_COUNTS } = endpoints;
@ -181,13 +196,6 @@ class Kolide extends Base {
});
}
getHosts = () => {
const { HOSTS } = endpoints;
return this.authenticatedGet(this.endpoint(HOSTS))
.then(response => response.hosts);
}
getLabelHosts = (labelID) => {
const { LABEL_HOSTS } = endpoints;
console.log(LABEL_HOSTS(labelID));

View File

@ -14,6 +14,7 @@ const {
validCreatePackRequest,
validCreateQueryRequest,
validCreateScheduledQueryRequest,
validDestroyHostRequest,
validDestroyLabelRequest,
validDestroyQueryRequest,
validDestroyPackRequest,
@ -150,6 +151,38 @@ describe('Kolide - API client', () => {
});
});
describe('hosts', () => {
describe('#loadAll', () => {
it('calls the correct endpoint with the correct params', (done) => {
const request = validGetHostsRequest(bearerToken);
Kolide.setBearerToken(bearerToken);
Kolide.hosts.loadAll()
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
});
describe('#destroy', () => {
it('calls the correct endpoint with the correct params', (done) => {
const request = validDestroyHostRequest(bearerToken, hostStub);
Kolide.setBearerToken(bearerToken);
Kolide.hosts.destroy(hostStub)
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(() => {
throw new Error('Expected the request to be stubbed');
});
});
});
});
describe('packs', () => {
it('#createPack', (done) => {
const { description, name } = packStub;
@ -343,20 +376,6 @@ describe('Kolide - API client', () => {
});
});
describe('#getHosts', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const request = validGetHostsRequest(bearerToken);
Kolide.setBearerToken(bearerToken);
Kolide.getHosts()
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
});
describe('#getInvites', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const request = validGetInvitesRequest(bearerToken);

View File

@ -22,11 +22,11 @@ import osqueryTableInterface from 'interfaces/osquery_table';
import paths from 'router/paths';
import QueryForm from 'components/forms/queries/QueryForm';
import QuerySidePanel from 'components/side_panels/QuerySidePanel';
import { renderFlash } from 'redux/nodes/notifications/actions';
import Rocker from 'components/buttons/Rocker';
import Button from 'components/buttons/Button';
import Modal from 'components/modals/Modal';
import { selectOsqueryTable } from 'redux/nodes/components/QueryPages/actions';
import { renderFlash } from 'redux/nodes/notifications/actions';
import statusLabelsInterface from 'interfaces/status_labels';
import iconClassForLabel from 'utilities/icon_class_for_label';
import platformIconClass from 'utilities/platform_icon_class';
@ -58,7 +58,8 @@ export class ManageHostsPage extends Component {
this.state = {
labelQueryText: '',
showDeleteModal: false,
selectedHost: null,
showDeleteLabelModal: false,
};
}
@ -72,14 +73,6 @@ export class ManageHostsPage extends Component {
return false;
}
onCancelAddLabel = () => {
const { dispatch } = this.props;
dispatch(push('/hosts/manage'));
return false;
}
onAddLabelClick = (evt) => {
evt.preventDefault();
@ -90,15 +83,29 @@ export class ManageHostsPage extends Component {
return false;
}
onHostDetailActionClick = (type) => {
return (host) => {
return (evt) => {
evt.preventDefault();
onCancelAddLabel = () => {
const { dispatch } = this.props;
console.log(type, host);
return false;
};
};
dispatch(push('/hosts/manage'));
return false;
}
onDestroyHost = (evt) => {
evt.preventDefault();
const { dispatch } = this.props;
const { selectedHost } = this.state;
dispatch(hostActions.destroy(selectedHost))
.then(() => {
this.toggleHostModal(null)();
dispatch(getStatusLabelCounts);
dispatch(renderFlash('success', `Host "${selectedHost.hostname}" was successfully deleted`));
});
return false;
}
onLabelClick = (selectedLabel) => {
@ -144,23 +151,36 @@ export class ManageHostsPage extends Component {
}
onDeleteLabel = () => {
const { toggleModal } = this;
const { toggleLabelModal } = this;
const { dispatch, selectedLabel } = this.props;
const { MANAGE_HOSTS } = paths;
return dispatch(labelActions.destroy(selectedLabel))
.then(() => {
toggleModal();
toggleLabelModal();
dispatch(push(MANAGE_HOSTS));
dispatch(renderFlash('success', 'Label successfully deleted'));
return false;
});
}
toggleModal = () => {
const { showDeleteModal } = this.state;
toggleHostModal = (selectedHost) => {
return () => {
const { showDeleteHostModal } = this.state;
this.setState({ showDeleteModal: !showDeleteModal });
this.setState({
selectedHost,
showDeleteHostModal: !showDeleteHostModal,
});
return false;
};
}
toggleLabelModal = () => {
const { showDeleteLabelModal } = this.state;
this.setState({ showDeleteLabelModal: !showDeleteLabelModal });
return false;
}
@ -177,23 +197,46 @@ export class ManageHostsPage extends Component {
return orderedHosts;
}
renderModal = () => {
const { showDeleteModal } = this.state;
const { toggleModal, onDeleteLabel } = this;
renderHostModal = () => {
const { showDeleteHostModal } = this.state;
const { toggleHostModal, onDestroyHost } = this;
if (!showDeleteModal) {
if (!showDeleteHostModal) {
return false;
}
return (
<Modal
title="Delete Host"
onExit={toggleHostModal(null)}
className={`${baseClass}__modal`}
>
<p>Are you sure you wish to delete this host?</p>
<div>
<Button onClick={toggleHostModal(null)} variant="inverse">Cancel</Button>
<Button onClick={onDestroyHost} variant="alert">Delete</Button>
</div>
</Modal>
);
}
renderLabelModal = () => {
const { showDeleteLabelModal } = this.state;
const { toggleLabelModal, onDeleteLabel } = this;
if (!showDeleteLabelModal) {
return false;
}
return (
<Modal
title="Delete Label"
onExit={toggleModal}
onExit={toggleLabelModal}
className={`${baseClass}__modal`}
>
<p>Are you sure you wish to delete this label?</p>
<div>
<Button onClick={toggleModal} variant="inverse">Cancel</Button>
<Button onClick={toggleLabelModal} variant="inverse">Cancel</Button>
<Button onClick={onDeleteLabel} variant="alert">Delete</Button>
</div>
</Modal>
@ -201,7 +244,7 @@ export class ManageHostsPage extends Component {
}
renderDeleteButton = () => {
const { toggleModal } = this;
const { toggleLabelModal } = this;
const { selectedLabel: { type } } = this.props;
if (type !== 'custom') {
@ -210,7 +253,7 @@ export class ManageHostsPage extends Component {
return (
<div className={`${baseClass}__delete-label`}>
<Button onClick={toggleModal} variant="alert">Delete</Button>
<Button onClick={toggleLabelModal} variant="alert">Delete</Button>
</div>
);
}
@ -328,7 +371,7 @@ export class ManageHostsPage extends Component {
renderHosts = () => {
const { display, isAddLabel, selectedLabel } = this.props;
const { onHostDetailActionClick, filterHosts, sortHosts, renderNoHosts } = this;
const { toggleHostModal, filterHosts, sortHosts, renderNoHosts } = this;
if (isAddLabel) {
return false;
@ -351,14 +394,13 @@ export class ManageHostsPage extends Component {
<HostDetails
host={host}
key={`host-${host.id}-details`}
onDisableClick={onHostDetailActionClick('disable')}
onQueryClick={onHostDetailActionClick('query')}
onDestroyHost={toggleHostModal}
/>
);
});
}
return <HostsTable hosts={sortedHosts} />;
return <HostsTable hosts={sortedHosts} onDestroyHost={toggleHostModal} />;
}
@ -426,7 +468,7 @@ export class ManageHostsPage extends Component {
}
render () {
const { renderForm, renderHeader, renderHosts, renderSidePanel, renderModal } = this;
const { renderForm, renderHeader, renderHosts, renderSidePanel, renderHostModal, renderLabelModal } = this;
const { display, isAddLabel } = this.props;
return (
@ -442,7 +484,8 @@ export class ManageHostsPage extends Component {
}
{renderSidePanel()}
{renderModal()}
{renderHostModal()}
{renderLabelModal()}
</div>
);
}

View File

@ -3,6 +3,7 @@ import expect, { spyOn, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import { noop } from 'lodash';
import hostActions from 'redux/nodes/entities/hosts/actions';
import labelActions from 'redux/nodes/entities/labels/actions';
import ConnectedManageHostsPage, { ManageHostsPage } from 'pages/hosts/ManageHostsPage/ManageHostsPage';
import { connectedComponent, createAceSpy, reduxMockStore, stubbedOsqueryTable } from 'test/helpers';
@ -221,4 +222,28 @@ describe('ManageHostsPage - component', () => {
expect(labelActions.destroy).toHaveBeenCalledWith(customLabel);
});
});
describe('Delete a host', () => {
it('Deleted host after confirmation modal', () => {
const ownProps = { location: {}, params: { active_label: 'all-hosts' } };
const component = connectedComponent(ConnectedManageHostsPage, { props: ownProps, mockStore });
const page = mount(component);
const deleteBtn = page.find('HostDetails').first().find('Button');
spyOn(hostActions, 'destroy').andCallThrough();
expect(page.find('Modal').length).toEqual(0);
deleteBtn.simulate('click');
const confirmModal = page.find('Modal');
expect(confirmModal.length).toEqual(1);
const confirmBtn = confirmModal.find('.button--alert');
confirmBtn.simulate('click');
expect(hostActions.destroy).toHaveBeenCalledWith(hostStub);
});
});
});

View File

@ -72,6 +72,12 @@
display: inline-block;
}
&__modal {
.button--alert {
margin-left: 10px;
}
}
&__list {
&--grid {
@include display(flex);

View File

@ -5,8 +5,9 @@ import schemas from '../base/schemas';
const { HOSTS: schema } = schemas;
export default reduxConfig({
destroyFunc: Kolide.hosts.destroy,
entityName: 'hosts',
loadAllFunc: Kolide.getHosts,
loadAllFunc: Kolide.hosts.loadAll,
parseEntityFunc: (host) => {
return { ...host, target_type: 'hosts' };
},

View File

@ -74,6 +74,16 @@ export const validCreateScheduledQueryRequest = (bearerToken, formData) => {
.reply(201, { scheduled_query: scheduledQueryStub });
};
export const validDestroyHostRequest = (bearerToken, host) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.delete(`/api/v1/kolide/hosts/${host.id}`)
.reply(200, {});
};
export const validDestroyLabelRequest = (bearerToken, label) => {
return nock('http://localhost:8080', {
reqHeaders: {
@ -439,6 +449,7 @@ export default {
validCreatePackRequest,
validCreateQueryRequest,
validCreateScheduledQueryRequest,
validDestroyHostRequest,
validDestroyLabelRequest,
validDestroyQueryRequest,
validDestroyPackRequest,