Add Query online hosts button (#1070)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user