Add Query online hosts button (#1070)

This commit is contained in:
Mike Stone
2017-01-20 18:07:54 -05:00
committed by Jason Meller
parent 54408ff9e4
commit bc9273622c
9 changed files with 256 additions and 33 deletions
@@ -13,7 +13,23 @@ export const STATUSES = {
offline: 'OFFLINE',
};
const HostDetails = ({ host, onDestroyHost }) => {
const ActionButton = ({ host, onDestroyHost, onQueryHost }) => {
if (host.status === 'online') {
return (
<Button onClick={onQueryHost(host)} variant="unstyled" title="Query this host">
<Icon name="query" className={`${baseClass}__cta-host-icon`} />
</Button>
);
}
return (
<Button onClick={onDestroyHost(host)} variant="unstyled" title="Delete this host">
<Icon name="trash" className={`${baseClass}__cta-host-icon`} />
</Button>
);
};
const HostDetails = ({ host, onDestroyHost, onQueryHost }) => {
const {
host_cpu: hostCpu,
host_mac: hostMac,
@@ -29,10 +45,8 @@ const HostDetails = ({ host, onDestroyHost }) => {
return (
<div className={`${baseClass} ${baseClass}--${status}`}>
<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 className={`${baseClass}__cta-host`}>
<ActionButton host={host} onDestroyHost={onDestroyHost} onQueryHost={onQueryHost} />
</span>
<p className={`${baseClass}__status`}>{status}</p>
@@ -79,9 +93,16 @@ const HostDetails = ({ host, onDestroyHost }) => {
);
};
ActionButton.propTypes = {
host: hostInterface.isRequired,
onDestroyHost: PropTypes.func.isRequired,
onQueryHost: PropTypes.func.isRequired,
};
HostDetails.propTypes = {
host: hostInterface.isRequired,
onDestroyHost: PropTypes.func.isRequired,
onQueryHost: PropTypes.func.isRequired,
};
export default HostDetails;
@@ -8,14 +8,70 @@ 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');
it('calls the onDestroyHost prop when the action button is clicked on an offline host', () => {
const destroySpy = createSpy();
const querySpy = createSpy();
const offlineHost = { ...hostStub, status: 'offline' };
const offlineComponent = mount(
<HostDetails
host={offlineHost}
onDestroyHost={destroySpy}
onQueryHost={querySpy}
/>
);
const btn = offlineComponent.find('Button');
expect(btn.find('Icon').prop('name')).toEqual('trash');
btn.simulate('click');
expect(spy).toHaveBeenCalled();
expect(destroySpy).toHaveBeenCalled();
expect(querySpy).toNotHaveBeenCalled();
});
it('calls the onDestroyHost prop when the action button is clicked on a mia host', () => {
const destroySpy = createSpy();
const querySpy = createSpy();
const miaHost = { ...hostStub, status: 'mia' };
const miaComponent = mount(
<HostDetails
host={miaHost}
onDestroyHost={destroySpy}
onQueryHost={querySpy}
/>
);
const btn = miaComponent.find('Button');
expect(btn.find('Icon').prop('name')).toEqual('trash');
btn.simulate('click');
expect(destroySpy).toHaveBeenCalled();
expect(querySpy).toNotHaveBeenCalled();
});
it('calls the onQueryHost prop when the action button is clicked on an online host', () => {
const destroySpy = createSpy();
const querySpy = createSpy();
const onlineHost = { ...hostStub, status: 'online' };
const onlineComponent = mount(
<HostDetails
host={onlineHost}
onDestroyHost={destroySpy}
onQueryHost={querySpy}
/>
);
const btn = onlineComponent.find('Button');
expect(btn.find('Icon').prop('name')).toEqual('query');
btn.simulate('click');
expect(destroySpy).toNotHaveBeenCalled();
expect(querySpy).toHaveBeenCalled();
});
});
@@ -44,7 +44,7 @@
margin: 0;
}
&__delete-host {
&__cta-host {
float: right;
span {
@@ -55,7 +55,7 @@
}
}
&__delete-host-icon {
&__cta-host-icon {
color: $link;
font-size: 20px;
}
@@ -9,14 +9,29 @@ import iconClassForLabel from 'utilities/icon_class_for_label';
const baseClass = 'hosts-table';
const ActionButton = ({ host, onDestroyHost, onQueryHost }) => {
if (host.status === 'online') {
return <Button onClick={onQueryHost(host)} variant="unstyled"><Icon name="query" /></Button>;
}
return <Button onClick={onDestroyHost(host)} variant="unstyled"><Icon name="trash" /></Button>;
};
ActionButton.propTypes = {
host: hostInterface,
onDestroyHost: PropTypes.func,
onQueryHost: PropTypes.func,
};
class HostsTable extends Component {
static propTypes = {
hosts: PropTypes.arrayOf(hostInterface),
onDestroyHost: PropTypes.func,
onQueryHost: PropTypes.func,
};
renderHost = (host) => {
const { onDestroyHost } = this.props;
const { onDestroyHost, onQueryHost } = this.props;
const statusClassName = classnames(`${baseClass}__status`, `${baseClass}__status--${host.status}`);
return (
@@ -27,7 +42,7 @@ class HostsTable extends Component {
<td>{host.osquery_version}</td>
<td>{host.host_ip_address}</td>
<td>{host.host_mac}</td>
<td><Button onClick={onDestroyHost(host)} variant="unstyled"><Icon name="trash" /></Button></td>
<td><ActionButton host={host} onDestroyHost={onDestroyHost} onQueryHost={onQueryHost} /></td>
</tr>
);
}
@@ -8,13 +8,69 @@ 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');
it('calls the onDestroyHost prop when the action button is clicked on an offline host', () => {
const destroySpy = createSpy();
const querySpy = createSpy();
const offlineHost = { ...hostStub, status: 'offline' };
const offlineComponent = mount(
<HostsTable
hosts={[offlineHost]}
onDestroyHost={destroySpy}
onQueryHost={querySpy}
/>
);
const btn = offlineComponent.find('Button');
expect(btn.find('Icon').prop('name')).toEqual('trash');
btn.simulate('click');
expect(spy).toHaveBeenCalled();
expect(destroySpy).toHaveBeenCalled();
expect(querySpy).toNotHaveBeenCalled();
});
it('calls the onDestroyHost prop when the action button is clicked on a mia host', () => {
const destroySpy = createSpy();
const querySpy = createSpy();
const miaHost = { ...hostStub, status: 'mia' };
const miaComponent = mount(
<HostsTable
hosts={[miaHost]}
onDestroyHost={destroySpy}
onQueryHost={querySpy}
/>
);
const btn = miaComponent.find('Button');
expect(btn.find('Icon').prop('name')).toEqual('trash');
btn.simulate('click');
expect(destroySpy).toHaveBeenCalled();
expect(querySpy).toNotHaveBeenCalled();
});
it('calls the onQueryHost prop when the action button is clicked on an online host', () => {
const destroySpy = createSpy();
const querySpy = createSpy();
const onlineHost = { ...hostStub, status: 'online' };
const onlineComponent = mount(
<HostsTable
hosts={[onlineHost]}
onDestroyHost={destroySpy}
onQueryHost={querySpy}
/>
);
const btn = onlineComponent.find('Button');
expect(btn.find('Icon').prop('name')).toEqual('query');
btn.simulate('click');
expect(destroySpy).toNotHaveBeenCalled();
expect(querySpy).toHaveBeenCalled();
});
});
@@ -95,9 +95,8 @@ export class ManageHostsPage extends Component {
onAddHostSubmit = () => {
const { toggleAddHostModal } = this;
toggleAddHostModal();
console.log('Submitted the Add Host Modal');
toggleAddHostModal();
return false;
}
@@ -184,6 +183,22 @@ export class ManageHostsPage extends Component {
});
}
onQueryHost = (host) => {
return (evt) => {
evt.preventDefault();
const { dispatch } = this.props;
const { NEW_QUERY } = paths;
dispatch(push({
pathname: NEW_QUERY,
query: { host_ids: [host.id] },
}));
return false;
};
}
toggleAddHostModal = () => {
const { showAddHostModal } = this.state;
this.setState({ showAddHostModal: !showAddHostModal });
@@ -420,7 +435,7 @@ export class ManageHostsPage extends Component {
renderHosts = () => {
const { display, isAddLabel, selectedLabel } = this.props;
const { toggleDeleteHostModal, filterHosts, sortHosts, renderNoHosts, toggleAddHostModal } = this;
const { toggleDeleteHostModal, filterHosts, onQueryHost, sortHosts, renderNoHosts, toggleAddHostModal } = this;
if (isAddLabel) {
return false;
@@ -444,12 +459,19 @@ export class ManageHostsPage extends Component {
host={host}
key={`host-${host.id}-details`}
onDestroyHost={toggleDeleteHostModal}
onQueryHost={onQueryHost}
/>
);
});
}
return <HostsTable hosts={sortedHosts} onDestroyHost={toggleDeleteHostModal} />;
return (
<HostsTable
hosts={sortedHosts}
onDestroyHost={toggleDeleteHostModal}
onQueryHost={onQueryHost}
/>
);
}
@@ -228,7 +228,7 @@ describe('ManageHostsPage - component', () => {
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');
const deleteBtn = page.find('HostDetails').last().find('Button');
spyOn(hostActions, 'destroy').andCallThrough();
@@ -243,7 +243,7 @@ describe('ManageHostsPage - component', () => {
const confirmBtn = confirmModal.find('.button--alert');
confirmBtn.simulate('click');
expect(hostActions.destroy).toHaveBeenCalledWith(hostStub);
expect(hostActions.destroy).toHaveBeenCalledWith(offlineHost);
});
});
});
+42 -6
View File
@@ -1,7 +1,7 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import { first, isEqual, values } from 'lodash';
import { first, filter, includes, isArray, isEqual, values } from 'lodash';
import classnames from 'classnames';
import Kolide from 'kolide';
@@ -11,6 +11,7 @@ import debounce from 'utilities/debounce';
import deepDifference from 'utilities/deep_difference';
import entityGetter from 'redux/utilities/entityGetter';
import { formatSelectedTargetsForApi } from 'kolide/helpers';
import hostActions from 'redux/nodes/entities/hosts/actions';
import QueryForm from 'components/forms/queries/QueryForm';
import osqueryTableInterface from 'interfaces/osquery_table';
import queryActions from 'redux/nodes/entities/queries/actions';
@@ -32,6 +33,7 @@ class QueryPage extends Component {
errors: PropTypes.shape({
base: PropTypes.string,
}),
hostIDs: PropTypes.oneOfType([PropTypes.array, PropTypes.string]),
query: queryInterface,
selectedOsqueryTable: osqueryTableInterface,
selectedTargets: PropTypes.arrayOf(targetInterface),
@@ -47,6 +49,16 @@ class QueryPage extends Component {
};
}
componentWillMount () {
const { dispatch, hostIDs } = this.props;
if (hostIDs) {
dispatch(hostActions.loadAll());
}
return false;
}
componentWillUnmount () {
const { destroyCampaign, removeSocket } = this;
@@ -289,17 +301,41 @@ class QueryPage extends Component {
}
}
const mapStateToProps = (state, { params }) => {
const { id: queryID } = params;
const { entities: campaigns } = entityGetter(state).get('campaigns');
const mapStateToProps = (state, ownProps) => {
const stateEntities = entityGetter(state);
const { id: queryID } = ownProps.params;
const { entities: campaigns } = stateEntities.get('campaigns');
const reduxQuery = entityGetter(state).get('queries').findBy({ id: queryID });
const { queryText, selectedOsqueryTable, selectedTargets } = state.components.QueryPages;
const { queryText, selectedOsqueryTable } = state.components.QueryPages;
const campaign = first(values(campaigns));
const { errors } = state.entities.queries;
const queryStub = { description: '', name: '', query: queryText };
const query = reduxQuery || queryStub;
let { selectedTargets } = state.components.QueryPages;
const { host_ids: hostIDs } = ownProps.location.query;
return { campaign, errors, query, selectedOsqueryTable, selectedTargets };
// hostIDs are URL params so they are strings
if (hostIDs && !queryID) {
const { entities: hosts } = stateEntities.get('hosts');
let hostFilter;
if (isArray(hostIDs)) {
hostFilter = h => includes(hostIDs, String(h.id));
} else {
hostFilter = { id: Number(hostIDs) };
}
selectedTargets = filter(hosts, hostFilter);
}
return {
campaign,
errors,
hostIDs,
query,
selectedOsqueryTable,
selectedTargets,
};
};
export default connect(mapStateToProps)(QueryPage);
@@ -7,9 +7,10 @@ import kolide from 'kolide';
import queryActions from 'redux/nodes/entities/queries/actions';
import QueryPage from 'pages/queries/QueryPage';
import { validUpdateQueryRequest } from 'test/mocks';
import { hostStub } from 'test/stubs';
const { connectedComponent, createAceSpy, fillInFormInput, reduxMockStore } = helpers;
const locationProp = { params: {} };
const locationProp = { params: {}, location: { query: {} } };
describe('QueryPage - component', () => {
beforeEach(createAceSpy);
@@ -24,6 +25,12 @@ describe('QueryPage - component', () => {
},
},
entities: {
hosts: {
data: {
[hostStub.id]: hostStub,
99: { ...hostStub, id: 99 },
},
},
queries: {},
targets: {},
},
@@ -41,6 +48,16 @@ describe('QueryPage - component', () => {
expect(page.find('QuerySidePanel').length).toEqual(1);
});
it('sets selectedTargets based on host_ids', () => {
const singleHostProps = { params: {}, location: { query: { host_ids: String(hostStub.id) } } };
const multipleHostsProps = { params: {}, location: { query: { host_ids: [String(hostStub.id), '99'] } } };
const singleHostPage = mount(connectedComponent(QueryPage, { mockStore, props: singleHostProps }));
const multipleHostsPage = mount(connectedComponent(QueryPage, { mockStore, props: multipleHostsProps }));
expect(singleHostPage.find('QueryPage').prop('selectedTargets')).toEqual([hostStub]);
expect(multipleHostsPage.find('QueryPage').prop('selectedTargets')).toEqual([hostStub, { ...hostStub, id: 99 }]);
});
it('sets targetError in state when the query is run and there are no selected targets', () => {
const page = mount(connectedComponent(QueryPage, { mockStore, props: locationProp }));
const form = page.find('QueryForm');
@@ -56,7 +73,7 @@ describe('QueryPage - component', () => {
it('calls the onUpdateQuery prop when the query is updated', () => {
spyOn(queryActions, 'update').andCallThrough();
const bearerToken = 'abc123';
const locationWithQueryProp = { params: { id: 1 } };
const locationWithQueryProp = { params: { id: 1 }, location: { query: {} } };
const query = { id: 1, name: 'My query', description: 'My query description', query: 'select * from users' };
const mockStoreWithQuery = reduxMockStore({
components: {