diff --git a/frontend/components/hosts/HostDetails/HostDetails.jsx b/frontend/components/hosts/HostDetails/HostDetails.jsx
index 27e3492c63..10dd98f52a 100644
--- a/frontend/components/hosts/HostDetails/HostDetails.jsx
+++ b/frontend/components/hosts/HostDetails/HostDetails.jsx
@@ -13,7 +13,23 @@ export const STATUSES = {
offline: 'OFFLINE',
};
-const HostDetails = ({ host, onDestroyHost }) => {
+const ActionButton = ({ host, onDestroyHost, onQueryHost }) => {
+ if (host.status === 'online') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const HostDetails = ({ host, onDestroyHost, onQueryHost }) => {
const {
host_cpu: hostCpu,
host_mac: hostMac,
@@ -29,10 +45,8 @@ const HostDetails = ({ host, onDestroyHost }) => {
return (
-
-
+
+
{status}
@@ -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;
diff --git a/frontend/components/hosts/HostDetails/HostDetails.tests.jsx b/frontend/components/hosts/HostDetails/HostDetails.tests.jsx
index 25a0bc13db..67422e1fc8 100644
--- a/frontend/components/hosts/HostDetails/HostDetails.tests.jsx
+++ b/frontend/components/hosts/HostDetails/HostDetails.tests.jsx
@@ -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();
- 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ const btn = onlineComponent.find('Button');
+
+ expect(btn.find('Icon').prop('name')).toEqual('query');
+
+ btn.simulate('click');
+
+ expect(destroySpy).toNotHaveBeenCalled();
+ expect(querySpy).toHaveBeenCalled();
});
});
diff --git a/frontend/components/hosts/HostDetails/_styles.scss b/frontend/components/hosts/HostDetails/_styles.scss
index c3386bbf3d..902d8ea2aa 100644
--- a/frontend/components/hosts/HostDetails/_styles.scss
+++ b/frontend/components/hosts/HostDetails/_styles.scss
@@ -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;
}
diff --git a/frontend/components/hosts/HostsTable/HostsTable.jsx b/frontend/components/hosts/HostsTable/HostsTable.jsx
index 1476003618..1f8e7bbfb5 100644
--- a/frontend/components/hosts/HostsTable/HostsTable.jsx
+++ b/frontend/components/hosts/HostsTable/HostsTable.jsx
@@ -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 ;
+ }
+
+ return ;
+};
+
+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 {
{host.osquery_version} |
{host.host_ip_address} |
{host.host_mac} |
-
|
+
|
);
}
diff --git a/frontend/components/hosts/HostsTable/HostsTable.tests.jsx b/frontend/components/hosts/HostsTable/HostsTable.tests.jsx
index affd36bd52..1b400b5c45 100644
--- a/frontend/components/hosts/HostsTable/HostsTable.tests.jsx
+++ b/frontend/components/hosts/HostsTable/HostsTable.tests.jsx
@@ -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(
);
- 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ const btn = onlineComponent.find('Button');
+
+ expect(btn.find('Icon').prop('name')).toEqual('query');
+
+ btn.simulate('click');
+
+ expect(destroySpy).toNotHaveBeenCalled();
+ expect(querySpy).toHaveBeenCalled();
});
});
diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
index 8f3d3a680e..5a456a3565 100644
--- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
+++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
@@ -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
;
+ return (
+
+ );
}
diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.jsx
index 856e4eee85..ea721c4a3d 100644
--- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.jsx
+++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.jsx
@@ -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);
});
});
});
diff --git a/frontend/pages/queries/QueryPage/QueryPage.jsx b/frontend/pages/queries/QueryPage/QueryPage.jsx
index 751bd5cb15..d0406dd3cf 100644
--- a/frontend/pages/queries/QueryPage/QueryPage.jsx
+++ b/frontend/pages/queries/QueryPage/QueryPage.jsx
@@ -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);
diff --git a/frontend/pages/queries/QueryPage/QueryPage.tests.js b/frontend/pages/queries/QueryPage/QueryPage.tests.js
index 8e28e6edfa..7742fd4248 100644
--- a/frontend/pages/queries/QueryPage/QueryPage.tests.js
+++ b/frontend/pages/queries/QueryPage/QueryPage.tests.js
@@ -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: {