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: {