diff --git a/frontend/components/forms/fields/InputField/InputField.jsx b/frontend/components/forms/fields/InputField/InputField.jsx
index c47ac5ffd9..9de03c18d4 100644
--- a/frontend/components/forms/fields/InputField/InputField.jsx
+++ b/frontend/components/forms/fields/InputField/InputField.jsx
@@ -1,6 +1,6 @@
import React, { Component, PropTypes } from 'react';
import classnames from 'classnames';
-import { pick } from 'lodash';
+import { noop, pick } from 'lodash';
import FormField from 'components/forms/FormField';
@@ -18,6 +18,7 @@ class InputField extends Component {
labelClassName: PropTypes.string,
name: PropTypes.string,
onChange: PropTypes.func,
+ onFocus: PropTypes.func,
placeholder: PropTypes.string,
type: PropTypes.string,
value: PropTypes.string.isRequired,
@@ -29,6 +30,7 @@ class InputField extends Component {
inputOptions: {},
label: null,
labelClassName: '',
+ onFocus: noop,
type: 'text',
value: '',
};
@@ -54,7 +56,17 @@ class InputField extends Component {
}
render () {
- const { error, inputClassName, inputOptions, inputWrapperClass, name, placeholder, type, value } = this.props;
+ const {
+ error,
+ inputClassName,
+ inputOptions,
+ inputWrapperClass,
+ name,
+ onFocus,
+ placeholder,
+ type,
+ value,
+ } = this.props;
const { onInputChange } = this;
const shouldShowPasswordClass = type === 'password';
const inputClasses = classnames(baseClass, inputClassName, {
@@ -87,6 +99,7 @@ class InputField extends Component {
{ this.input = r; }}
diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/SelectTargetsDropdown.jsx b/frontend/components/forms/fields/SelectTargetsDropdown/SelectTargetsDropdown.jsx
index 16c255b289..3edeff6a3b 100644
--- a/frontend/components/forms/fields/SelectTargetsDropdown/SelectTargetsDropdown.jsx
+++ b/frontend/components/forms/fields/SelectTargetsDropdown/SelectTargetsDropdown.jsx
@@ -4,7 +4,7 @@ import classnames from 'classnames';
import Kolide from 'kolide';
import targetInterface from 'interfaces/target';
-import { formatSelectedTargetsForApi } from './helpers';
+import { formatSelectedTargetsForApi } from 'kolide/helpers';
import Input from './SelectTargetsInput';
import Menu from './SelectTargetsMenu';
diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/helpers.js b/frontend/components/forms/fields/SelectTargetsDropdown/helpers.js
deleted file mode 100644
index 20fb7d3155..0000000000
--- a/frontend/components/forms/fields/SelectTargetsDropdown/helpers.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { flatMap } from 'lodash';
-
-const filterTarget = (targetType) => {
- return (target) => {
- return target.target_type === targetType ? [target.id] : [];
- };
-};
-
-export const formatSelectedTargetsForApi = (selectedTargets) => {
- const targets = selectedTargets || [];
- const hosts = flatMap(targets, filterTarget('hosts'));
- const labels = flatMap(targets, filterTarget('labels'));
-
- return { hosts, labels };
-};
-
-export default { formatSelectedTargetsForApi };
diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/helpers.tests.js b/frontend/components/forms/fields/SelectTargetsDropdown/helpers.tests.js
deleted file mode 100644
index ce43d35037..0000000000
--- a/frontend/components/forms/fields/SelectTargetsDropdown/helpers.tests.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import expect from 'expect';
-
-import helpers from './helpers';
-
-const label1 = { id: 1, target_type: 'labels' };
-const label2 = { id: 2, target_type: 'labels' };
-const host1 = { id: 6, target_type: 'hosts' };
-const host2 = { id: 5, target_type: 'hosts' };
-
-describe('SelectTargetsDropdown - helpers', () => {
- describe('#formatSelectedTargetsForApi', () => {
- const { formatSelectedTargetsForApi } = helpers;
-
- it('splits targets into labels and hosts', () => {
- const targets = [host1, host2, label1, label2];
-
- expect(formatSelectedTargetsForApi(targets)).toEqual({
- hosts: [6, 5],
- labels: [1, 2],
- });
- });
- });
-});
diff --git a/frontend/components/forms/queries/QueryForm/QueryForm.jsx b/frontend/components/forms/queries/QueryForm/QueryForm.jsx
index 9989083362..4075c56a10 100644
--- a/frontend/components/forms/queries/QueryForm/QueryForm.jsx
+++ b/frontend/components/forms/queries/QueryForm/QueryForm.jsx
@@ -15,8 +15,10 @@ class QueryForm extends Component {
onCancel: PropTypes.func,
onRunQuery: PropTypes.func,
onSave: PropTypes.func,
+ onStopQuery: PropTypes.func,
onUpdate: PropTypes.func,
query: queryInterface,
+ queryIsRunning: PropTypes.bool,
queryText: PropTypes.string.isRequired,
queryType: PropTypes.string,
};
@@ -163,8 +165,35 @@ class QueryForm extends Component {
renderButtons = () => {
const { canSaveAsNew, canSaveChanges } = helpers;
const { formData } = this.state;
- const { onRunQuery, query, queryType } = this.props;
+ const {
+ onRunQuery,
+ onStopQuery,
+ query,
+ queryIsRunning,
+ queryType,
+ } = this.props;
const { onCancel, onSave, onUpdate } = this;
+ let runQueryButton;
+
+ if (queryIsRunning) {
+ runQueryButton = (
+
+ );
+ } else {
+ runQueryButton = (
+
+ );
+ }
if (queryType === 'label') {
return (
@@ -202,12 +231,7 @@ class QueryForm extends Component {
text="Save As New..."
variant="success"
/>
-
+ {runQueryButton}
);
}
diff --git a/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx b/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx
index 74fa32b3d0..40f2c10861 100644
--- a/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx
+++ b/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx
@@ -25,6 +25,36 @@ describe('QueryForm - component', () => {
expect(inputFields.find({ name: 'description' }).length).toEqual(1);
});
+ it('renders a "stop query" button when a query is running', () => {
+ const form = mount();
+ const runQueryBtn = form.find('.query-form__run-query-btn');
+ const stopQueryBtn = form.find('.query-form__stop-query-btn');
+
+ expect(runQueryBtn.length).toEqual(0);
+ expect(stopQueryBtn.length).toEqual(1);
+ });
+
+ it('renders a "run query" button when a query is not running', () => {
+ const form = mount();
+ const runQueryBtn = form.find('.query-form__run-query-btn');
+ const stopQueryBtn = form.find('.query-form__stop-query-btn');
+
+ expect(runQueryBtn.length).toEqual(1);
+ expect(stopQueryBtn.length).toEqual(0);
+ });
+
+ it('calls the onStopQuery prop when the stop query button is clicked', () => {
+ const onStopQuerySpy = createSpy();
+ const form = mount(
+
+ );
+ const stopQueryBtn = form.find('.query-form__stop-query-btn');
+
+ stopQueryBtn.simulate('click');
+
+ expect(onStopQuerySpy).toHaveBeenCalled();
+ });
+
it('updates state on input field change', () => {
const form = mount();
const inputFields = form.find('InputField');
diff --git a/frontend/components/queries/QueryComposer/QueryComposer.jsx b/frontend/components/queries/QueryComposer/QueryComposer.jsx
index 116106b4c0..cb630e8f99 100644
--- a/frontend/components/queries/QueryComposer/QueryComposer.jsx
+++ b/frontend/components/queries/QueryComposer/QueryComposer.jsx
@@ -19,10 +19,12 @@ class QueryComposer extends Component {
onOsqueryTableSelect: PropTypes.func,
onRunQuery: PropTypes.func,
onSave: PropTypes.func,
+ onStopQuery: PropTypes.func,
onTargetSelect: PropTypes.func,
onTextEditorInputChange: PropTypes.func,
onUpdate: PropTypes.func,
query: queryInterface,
+ queryIsRunning: PropTypes.bool,
queryType: PropTypes.string,
selectedTargets: PropTypes.arrayOf(targetInterface),
targetsCount: PropTypes.number,
@@ -56,8 +58,10 @@ class QueryComposer extends Component {
onFormCancel,
onRunQuery,
onSave,
+ onStopQuery,
onUpdate,
query,
+ queryIsRunning,
queryText,
queryType,
} = this.props;
@@ -67,8 +71,10 @@ class QueryComposer extends Component {
onCancel={onFormCancel}
onRunQuery={onRunQuery}
onSave={onSave}
+ onStopQuery={onStopQuery}
onUpdate={onUpdate}
query={query}
+ queryIsRunning={queryIsRunning}
queryType={queryType}
queryText={queryText}
/>
@@ -105,7 +111,7 @@ class QueryComposer extends Component {
}
render () {
- const { onTextEditorInputChange, queryText, queryType } = this.props;
+ const { onTextEditorInputChange, queryIsRunning, queryText, queryType } = this.props;
const { onLoad, renderForm, renderTargetsInput } = this;
return (
@@ -122,6 +128,7 @@ class QueryComposer extends Component {
name="query-editor"
onLoad={onLoad}
onChange={onTextEditorInputChange}
+ readOnly={queryIsRunning}
setOptions={{ enableLinking: true }}
showGutter
showPrintMargin={false}
diff --git a/frontend/components/queries/QueryResultsTable/QueryResultsTable.jsx b/frontend/components/queries/QueryResultsTable/QueryResultsTable.jsx
new file mode 100644
index 0000000000..917b439efe
--- /dev/null
+++ b/frontend/components/queries/QueryResultsTable/QueryResultsTable.jsx
@@ -0,0 +1,153 @@
+import React, { Component } from 'react';
+import classnames from 'classnames';
+import { get, keys, omit, values } from 'lodash';
+
+import campaignInterface from 'interfaces/campaign';
+import filterArrayByHash from 'utilities/filter_array_by_hash';
+import Icon from 'components/Icon';
+import InputField from 'components/forms/fields/InputField';
+import ProgressBar from 'components/ProgressBar';
+
+const baseClass = 'query-results-table';
+
+class QueryResultsTable extends Component {
+ static propTypes = {
+ campaign: campaignInterface.isRequired,
+ };
+
+ constructor (props) {
+ super(props);
+
+ this.state = { resultsFilter: {} };
+ }
+
+ onFilterAttribute = (attribute) => {
+ return (value) => {
+ const { resultsFilter } = this.state;
+
+ this.setState({
+ resultsFilter: {
+ ...resultsFilter,
+ [attribute]: value,
+ },
+ });
+
+ return false;
+ };
+ }
+
+ onSetActiveColumn = (activeColumn) => {
+ return () => {
+ this.setState({ activeColumn });
+ };
+ }
+
+ renderProgressDetails = () => {
+ const { campaign } = this.props;
+ const totalHostsCount = get(campaign, 'totals.count', 0);
+ const totalHostsReturned = get(campaign, 'hosts.length', 0);
+ const totalRowsCount = get(campaign, 'query_results.length', 0);
+
+ return (
+
+
+ {totalHostsReturned} of
+ {totalHostsCount} Hosts Returning
+ {totalRowsCount} Records
+
+
+
+ );
+ }
+
+ renderTableHeaderRowData = (column, index) => {
+ const { onFilterAttribute, onSetActiveColumn } = this;
+ const { activeColumn, resultsFilter } = this.state;
+ const filterIconClassName = classnames(`${baseClass}__filter-icon`, {
+ [`${baseClass}__filter-icon--is-active`]: activeColumn === column,
+ });
+
+ return (
+
+ {column}
+
+ |
+ );
+ }
+
+ renderTableHeaderRow = () => {
+ const { campaign } = this.props;
+ const { renderTableHeaderRowData } = this;
+ const { query_results: queryResults } = campaign;
+
+ const queryAttrs = omit(queryResults[0], ['hostname']);
+ const queryResultColumns = keys(queryAttrs);
+
+ return (
+
+ {renderTableHeaderRowData('hostname', -1)}
+ {queryResultColumns.map((column, i) => {
+ return renderTableHeaderRowData(column, i);
+ })}
+
+ );
+ }
+
+ renderTableRows = () => {
+ const { campaign } = this.props;
+ const { query_results: queryResults } = campaign;
+ const { resultsFilter } = this.state;
+ const filteredQueryResults = filterArrayByHash(queryResults, resultsFilter);
+
+ return filteredQueryResults.map((row, index) => {
+ const queryAttrs = omit(row, ['hostname']);
+ const queryResult = values(queryAttrs);
+
+ return (
+
+ | {row.hostname} |
+ {queryResult.map((attribute, i) => {
+ return {attribute} | ;
+ })}
+
+ );
+ });
+ }
+
+ render () {
+ const { campaign } = this.props;
+ const {
+ renderProgressDetails,
+ renderTableHeaderRow,
+ renderTableRows,
+ } = this;
+ const { query_results: queryResults } = campaign;
+
+ if (!queryResults || !queryResults.length) {
+ return false;
+ }
+
+ return (
+
+ {renderProgressDetails()}
+
+
+
+ {renderTableHeaderRow()}
+
+
+ {renderTableRows()}
+
+
+
+
+ );
+ }
+}
+
+export default QueryResultsTable;
diff --git a/frontend/components/queries/QueryResultsTable/QueryResultsTable.tests.jsx b/frontend/components/queries/QueryResultsTable/QueryResultsTable.tests.jsx
new file mode 100644
index 0000000000..31c123689f
--- /dev/null
+++ b/frontend/components/queries/QueryResultsTable/QueryResultsTable.tests.jsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import expect from 'expect';
+import { keys } from 'lodash';
+import { mount } from 'enzyme';
+
+import QueryResultsTable from 'components/queries/QueryResultsTable';
+
+const host = {
+ detail_updated_at: '2016-10-25T16:24:27.679472917-04:00',
+ hostname: 'jmeller-mbp.local',
+ id: 1,
+ ip: '192.168.1.10',
+ mac: '10:11:12:13:14:15',
+ memory: 4145483776,
+ os_version: 'Mac OS X 10.11.6',
+ osquery_version: '2.0.0',
+ platform: 'darwin',
+ status: 'online',
+ updated_at: '0001-01-01T00:00:00Z',
+ uptime: 3600000000000,
+ uuid: '1234-5678-9101',
+};
+const queryResult = {
+ distributed_query_execution_id: 4,
+ host,
+ rows: [{ cwd: '/' }],
+};
+
+const campaignWithNoQueryResults = {
+ created_at: '0001-01-01T00:00:00Z',
+ deleted: false,
+ deleted_at: null,
+ id: 4,
+ query_id: 12,
+ status: 0,
+ totals: {
+ count: 3,
+ online: 2,
+ },
+ updated_at: '0001-01-01T00:00:00Z',
+ user_id: 1,
+};
+const campaignWithQueryResults = {
+ ...campaignWithNoQueryResults,
+ query_results: [
+ { hostname: host.hostname, cwd: '/' },
+ ],
+};
+
+describe('QueryResultsTable - component', () => {
+ const componentWithoutQueryResults = mount(
+
+ );
+ const componentWithQueryResults = mount(
+
+ );
+
+ it('renders', () => {
+ expect(componentWithoutQueryResults.length).toEqual(1);
+ expect(componentWithQueryResults.length).toEqual(1);
+ });
+
+ it('does not return HTML when there are no query results', () => {
+ expect(componentWithoutQueryResults.html()).toNotExist();
+ });
+
+ it('renders a ProgressBar component', () => {
+ expect(
+ componentWithQueryResults.find('ProgressBar').length
+ ).toEqual(1);
+ });
+
+ it('sets the column headers to the keys of the query results', () => {
+ const queryResultKeys = keys(queryResult.rows[0]);
+ const tableHeaderText = componentWithQueryResults.find('thead').text();
+
+ queryResultKeys.forEach((key) => {
+ expect(tableHeaderText).toInclude(key);
+ });
+ });
+});
diff --git a/frontend/components/queries/QueryResultsTable/_styles.scss b/frontend/components/queries/QueryResultsTable/_styles.scss
new file mode 100644
index 0000000000..3e42ac214d
--- /dev/null
+++ b/frontend/components/queries/QueryResultsTable/_styles.scss
@@ -0,0 +1,49 @@
+.query-results-table {
+ background-color: $white;
+ margin-top: 29px;
+ padding: $pad-base;
+
+ &__filter-icon {
+ &--is-active {
+ color: $brand;
+ }
+ }
+
+ &__progress-details {
+ display: inline-block;
+ width: 378px;
+ }
+
+ &__table-wrapper {
+ border: solid 1px $accent-dark;
+ border-radius: 3px;
+ box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12);
+ max-height: 550px;
+ overflow: scroll;
+ }
+
+ &__table {
+ border-collapse: collapse;
+ color: $text-medium;
+ font-size: $small;
+ width: 100%;
+ }
+
+ thead {
+ background-color: $bg-medium;
+ color: $text-ultradark;
+ text-align: left;
+
+ th {
+ padding: $pad-small $pad-xsmall;
+ }
+ }
+
+ tbody {
+ background-color: $white;
+
+ td {
+ padding: $pad-xsmall;
+ }
+ }
+}
diff --git a/frontend/components/queries/QueryResultsTable/index.js b/frontend/components/queries/QueryResultsTable/index.js
new file mode 100644
index 0000000000..748ad34e51
--- /dev/null
+++ b/frontend/components/queries/QueryResultsTable/index.js
@@ -0,0 +1 @@
+export default from './QueryResultsTable';
diff --git a/frontend/interfaces/campaign.js b/frontend/interfaces/campaign.js
new file mode 100644
index 0000000000..165de655aa
--- /dev/null
+++ b/frontend/interfaces/campaign.js
@@ -0,0 +1,7 @@
+import { PropTypes } from 'react';
+
+export default PropTypes.shape({
+ count: PropTypes.number,
+ id: PropTypes.number,
+ online: PropTypes.number,
+});
diff --git a/frontend/kolide/base.js b/frontend/kolide/base.js
index b6b4b183d5..35f3723685 100644
--- a/frontend/kolide/base.js
+++ b/frontend/kolide/base.js
@@ -11,9 +11,10 @@ const REQUEST_METHODS = {
class Base {
constructor () {
- const { origin } = global.window.location;
+ const { host, origin } = global.window.location;
this.baseURL = `${origin}/api`;
+ this.websocketBaseURL = `wss://${host}/api`;
this.bearerToken = local.getItem('auth_token');
}
diff --git a/frontend/kolide/endpoints.js b/frontend/kolide/endpoints.js
index 5d21d0eb9a..cf0ab903ca 100644
--- a/frontend/kolide/endpoints.js
+++ b/frontend/kolide/endpoints.js
@@ -13,6 +13,7 @@ export default {
PACKS: '/v1/kolide/packs',
QUERIES: '/v1/kolide/queries',
RESET_PASSWORD: '/v1/kolide/reset_password',
+ RUN_QUERY: '/v1/kolide/queries/run',
SETUP: '/v1/setup',
TARGETS: '/v1/kolide/targets',
USERS: '/v1/kolide/users',
diff --git a/frontend/kolide/helpers.js b/frontend/kolide/helpers.js
index 143e875470..fa7e2d5918 100644
--- a/frontend/kolide/helpers.js
+++ b/frontend/kolide/helpers.js
@@ -1,4 +1,4 @@
-import { kebabCase, pick } from 'lodash';
+import { flatMap, kebabCase, pick } from 'lodash';
import md5 from 'js-md5';
const ORG_INFO_ATTRS = ['org_name', 'org_logo_url'];
@@ -26,6 +26,20 @@ const labelSlug = (label) => {
return kebabCase(lowerDisplayText);
};
+const filterTarget = (targetType) => {
+ return (target) => {
+ return target.target_type === targetType ? [target.id] : [];
+ };
+};
+
+export const formatSelectedTargetsForApi = (selectedTargets) => {
+ const targets = selectedTargets || [];
+ const hosts = flatMap(targets, filterTarget('hosts'));
+ const labels = flatMap(targets, filterTarget('labels'));
+
+ return { hosts, labels };
+};
+
const setupData = (formData) => {
const orgInfo = pick(formData, ORG_INFO_ATTRS);
const adminInfo = pick(formData, ADMIN_ATTRS);
@@ -42,4 +56,4 @@ const setupData = (formData) => {
};
};
-export default { addGravatarUrlToResource, labelSlug, setupData };
+export default { addGravatarUrlToResource, formatSelectedTargetsForApi, labelSlug, setupData };
diff --git a/frontend/kolide/helpers.tests.js b/frontend/kolide/helpers.tests.js
index 9ec5a710c5..925899ed04 100644
--- a/frontend/kolide/helpers.tests.js
+++ b/frontend/kolide/helpers.tests.js
@@ -2,6 +2,11 @@ import expect from 'expect';
import helpers from 'kolide/helpers';
+const label1 = { id: 1, target_type: 'labels' };
+const label2 = { id: 2, target_type: 'labels' };
+const host1 = { id: 6, target_type: 'hosts' };
+const host2 = { id: 5, target_type: 'hosts' };
+
describe('Kolide API - helpers', () => {
describe('#labelSlug', () => {
it('creates a slug for the label', () => {
@@ -10,6 +15,19 @@ describe('Kolide API - helpers', () => {
});
});
+ describe('#formatSelectedTargetsForApi', () => {
+ const { formatSelectedTargetsForApi } = helpers;
+
+ it('splits targets into labels and hosts', () => {
+ const targets = [host1, host2, label1, label2];
+
+ expect(formatSelectedTargetsForApi(targets)).toEqual({
+ hosts: [6, 5],
+ labels: [1, 2],
+ });
+ });
+ });
+
describe('#setupData', () => {
const formData = {
email: 'hi@gnar.dog',
diff --git a/frontend/kolide/index.js b/frontend/kolide/index.js
index acefd7b4e0..0308cbe9d5 100644
--- a/frontend/kolide/index.js
+++ b/frontend/kolide/index.js
@@ -2,6 +2,7 @@ import { appendTargetTypeToTargets } from 'redux/nodes/entities/targets/helpers'
import Base from 'kolide/base';
import endpoints from 'kolide/endpoints';
import helpers from 'kolide/helpers';
+import local from 'utilities/local';
class Kolide extends Base {
createLabel = ({ description, name, query }) => {
@@ -148,9 +149,9 @@ class Kolide extends Base {
};
});
const stubbedLabels = [
- { id: 40, display_text: 'ONLINE', slug: 'online', type: 'status', count: 20 },
- { id: 50, display_text: 'OFFLINE', slug: 'offline', type: 'status', count: 2 },
- { id: 55, display_text: 'MIA', description: '(offline > 30 days)', slug: 'mia', type: 'status', count: 3 },
+ { id: 40, display_text: 'ONLINE', type: 'status', count: 20 },
+ { id: 50, display_text: 'OFFLINE', type: 'status', count: 2 },
+ { id: 55, display_text: 'MIA', description: '(offline > 30 days)', type: 'status', count: 3 },
];
return labels.concat(stubbedLabels);
@@ -237,6 +238,25 @@ class Kolide extends Base {
return this.authenticatedDelete(endpoint);
}
+ runQuery = ({ query, selected }) => {
+ const { RUN_QUERY } = endpoints;
+
+ return this.authenticatedPost(this.endpoint(RUN_QUERY), JSON.stringify({ query, selected }))
+ .then(response => response.campaign);
+ }
+
+ runQueryWebsocket = (campaignID) => {
+ return new Promise((resolve) => {
+ const socket = new global.WebSocket(`${this.websocketBaseURL}/v1/kolide/results/${campaignID}`);
+
+ socket.onopen = () => {
+ socket.send(JSON.stringify({ type: 'auth', data: { token: local.getItem('auth_token') } }));
+ };
+
+ return resolve(socket);
+ });
+ }
+
setup = (formData) => {
const { SETUP } = endpoints;
const setupData = helpers.setupData(formData);
diff --git a/frontend/kolide/index.tests.js b/frontend/kolide/index.tests.js
index e0f2d22b85..fda38c9e9c 100644
--- a/frontend/kolide/index.tests.js
+++ b/frontend/kolide/index.tests.js
@@ -22,6 +22,7 @@ const {
validMeRequest,
validResetPasswordRequest,
validRevokeInviteRequest,
+ validRunQueryRequest,
validSetupRequest,
validUpdateQueryRequest,
validUpdateUserRequest,
@@ -331,6 +332,22 @@ describe('Kolide - API client', () => {
});
});
+ describe('#runQuery', () => {
+ it('calls the appropriate endpoint with the correct parameters', (done) => {
+ const bearerToken = 'valid-bearer-token';
+ const data = { query: 'select * from users', selected: { hosts: [], labels: [] } };
+ const request = validRunQueryRequest(bearerToken, data);
+
+ Kolide.setBearerToken(bearerToken);
+ Kolide.runQuery(data)
+ .then(() => {
+ expect(request.isDone()).toEqual(true);
+ done();
+ })
+ .catch(done);
+ });
+ });
+
describe('#setup', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const formData = {
diff --git a/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx b/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx
index 4aa401a390..72ad9c1262 100644
--- a/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx
+++ b/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx
@@ -84,7 +84,7 @@ class UserManagementPage extends Component {
return dispatch(renderFlash('success', 'User forced to reset password', update(user, { force_password_reset: false })));
});
case 'revert_invitation':
- return dispatch(inviteActions.destroy({ entityID: user.id }))
+ return dispatch(inviteActions.destroy(user))
.then(() => {
return dispatch(renderFlash('success', 'Invite revoked'));
});
diff --git a/frontend/pages/queries/QueryPage/QueryPage.jsx b/frontend/pages/queries/QueryPage/QueryPage.jsx
index 86814fa59c..b50a98d917 100644
--- a/frontend/pages/queries/QueryPage/QueryPage.jsx
+++ b/frontend/pages/queries/QueryPage/QueryPage.jsx
@@ -1,13 +1,19 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
+import { first, isEqual, values } from 'lodash';
+import Kolide from 'kolide';
+import campaignActions from 'redux/nodes/entities/campaigns/actions';
+import campaignInterface from 'interfaces/campaign';
import debounce from 'utilities/debounce';
import entityGetter from 'redux/utilities/entityGetter';
+import { formatSelectedTargetsForApi } from 'kolide/helpers';
import QueryComposer from 'components/queries/QueryComposer';
import osqueryTableInterface from 'interfaces/osquery_table';
import queryActions from 'redux/nodes/entities/queries/actions';
import queryInterface from 'interfaces/query';
+import QueryResultsTable from 'components/queries/QueryResultsTable';
import QuerySidePanel from 'components/side_panels/QuerySidePanel';
import { renderFlash } from 'redux/nodes/notifications/actions';
import { selectOsqueryTable, setQueryText, setSelectedTargets, setSelectedTargetsQuery } from 'redux/nodes/components/QueryPages/actions';
@@ -16,6 +22,7 @@ import validateQuery from 'components/forms/validators/validate_query';
class QueryPage extends Component {
static propTypes = {
+ campaign: campaignInterface,
dispatch: PropTypes.func,
query: queryInterface,
queryText: PropTypes.string,
@@ -27,6 +34,7 @@ class QueryPage extends Component {
super(props);
this.state = {
+ queryIsRunning: false,
targetsCount: 0,
};
}
@@ -54,6 +62,15 @@ class QueryPage extends Component {
return false;
}
+ componentWillUnmount () {
+ const { destroyCampaign, removeSocket } = this;
+
+ removeSocket();
+ destroyCampaign();
+
+ return false;
+ }
+
onFetchTargets = (query, targetResponse) => {
const { dispatch } = this.props;
const {
@@ -86,7 +103,50 @@ class QueryPage extends Component {
return false;
}
- console.log('TODO: dispatch thunk to run query with', { queryText, selectedTargets });
+ const { create, update } = campaignActions;
+ const { destroyCampaign, removeSocket } = this;
+ const selected = formatSelectedTargetsForApi(selectedTargets);
+
+ removeSocket();
+ destroyCampaign();
+
+ dispatch(create({ query: queryText, selected }))
+ .then((campaignResponse) => {
+ return Kolide.runQueryWebsocket(campaignResponse.id)
+ .then((socket) => {
+ this.campaign = campaignResponse;
+ this.socket = socket;
+ this.setState({ queryIsRunning: true });
+
+ this.socket.onmessage = ({ data }) => {
+ const socketData = JSON.parse(data);
+ const { previousSocketData } = this;
+
+ if (previousSocketData && isEqual(socketData, previousSocketData)) {
+ this.previousSocketData = socketData;
+
+ return false;
+ }
+
+ return dispatch(update(this.campaign, socketData))
+ .then((updatedCampaign) => {
+ this.previousSocketData = socketData;
+ this.campaign = updatedCampaign;
+ });
+ };
+ });
+ })
+ .catch((campaignError) => {
+ if (campaignError === 'resource already created') {
+ dispatch(renderFlash('error', 'A campaign with the provided query text has already been created'));
+
+ return false;
+ }
+
+ dispatch(renderFlash('error', campaignError));
+
+ return false;
+ });
return false;
})
@@ -114,6 +174,16 @@ class QueryPage extends Component {
});
})
+ onStopQuery = (evt) => {
+ evt.preventDefault();
+
+ const { removeSocket } = this;
+
+ this.setState({ queryIsRunning: false });
+
+ return removeSocket();
+ }
+
onTargetSelect = (selectedTargets) => {
const { dispatch } = this.props;
@@ -141,18 +211,42 @@ class QueryPage extends Component {
return false;
};
+ destroyCampaign = () => {
+ const { campaign, dispatch } = this.props;
+ const { destroy } = campaignActions;
+
+ if (campaign) {
+ this.campaign = null;
+ dispatch(destroy(campaign));
+ }
+
+ return false;
+ }
+
+ removeSocket = () => {
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ this.previousSocketData = null;
+ }
+
+ return false;
+ }
+
render () {
const {
onFetchTargets,
onOsqueryTableSelect,
onRunQuery,
onSaveQueryFormSubmit,
+ onStopQuery,
onTargetSelect,
onTextEditorInputChange,
onUpdateQuery,
} = this;
- const { targetsCount } = this.state;
+ const { queryIsRunning, targetsCount } = this.state;
const {
+ campaign,
query,
queryText,
selectedOsqueryTable,
@@ -166,15 +260,18 @@ class QueryPage extends Component {
onOsqueryTableSelect={onOsqueryTableSelect}
onRunQuery={onRunQuery}
onSave={onSaveQueryFormSubmit}
+ onStopQuery={onStopQuery}
onTargetSelect={onTargetSelect}
onTextEditorInputChange={onTextEditorInputChange}
onUpdate={onUpdateQuery}
query={query}
+ queryIsRunning={queryIsRunning}
selectedTargets={selectedTargets}
targetsCount={targetsCount}
selectedOsqueryTable={selectedOsqueryTable}
queryText={queryText}
/>
+ {campaign && }
{
const { id: queryID } = params;
+ const { entities: campaigns } = entityGetter(state).get('campaigns');
const query = entityGetter(state).get('queries').findBy({ id: queryID });
const { queryText, selectedOsqueryTable, selectedTargets } = state.components.QueryPages;
+ const campaign = first(values(campaigns));
- return { query, queryText, selectedOsqueryTable, selectedTargets };
+ return { campaign, query, queryText, selectedOsqueryTable, selectedTargets };
};
export default connect(mapStateToProps)(QueryPage);
diff --git a/frontend/redux/nodes/entities/base/reduxConfig.js b/frontend/redux/nodes/entities/base/reduxConfig.js
index c09d7b8f1c..3d0949f391 100644
--- a/frontend/redux/nodes/entities/base/reduxConfig.js
+++ b/frontend/redux/nodes/entities/base/reduxConfig.js
@@ -141,7 +141,7 @@ const reduxConfig = ({
return destroyFunc(...args)
.then(() => {
- const { entityID } = args[0];
+ const { id: entityID } = args[0];
return dispatch(destroySuccess(entityID));
})
@@ -208,7 +208,9 @@ const reduxConfig = ({
if (!response) return {};
const { entities } = normalize(parse([response]), arrayOf(schema));
- return dispatch(updateSuccess(entities));
+ dispatch(updateSuccess(entities));
+
+ return response;
})
.catch((response) => {
const { errors } = response;
diff --git a/frontend/redux/nodes/entities/base/schemas.js b/frontend/redux/nodes/entities/base/schemas.js
index 95847ba737..621ade57ef 100644
--- a/frontend/redux/nodes/entities/base/schemas.js
+++ b/frontend/redux/nodes/entities/base/schemas.js
@@ -1,5 +1,6 @@
import { Schema } from 'normalizr';
+const campaignsSchema = new Schema('campaigns');
const hostsSchema = new Schema('hosts');
const invitesSchema = new Schema('invites');
const labelsSchema = new Schema('labels');
@@ -9,6 +10,7 @@ const targetsSchema = new Schema('targets');
const usersSchema = new Schema('users');
export default {
+ CAMPAIGNS: campaignsSchema,
HOSTS: hostsSchema,
INVITES: invitesSchema,
LABELS: labelsSchema,
diff --git a/frontend/redux/nodes/entities/campaigns/actions.js b/frontend/redux/nodes/entities/campaigns/actions.js
new file mode 100644
index 0000000000..b95c41eb89
--- /dev/null
+++ b/frontend/redux/nodes/entities/campaigns/actions.js
@@ -0,0 +1,3 @@
+import config from './config';
+
+export default config.actions;
diff --git a/frontend/redux/nodes/entities/campaigns/config.js b/frontend/redux/nodes/entities/campaigns/config.js
new file mode 100644
index 0000000000..9bc2a24366
--- /dev/null
+++ b/frontend/redux/nodes/entities/campaigns/config.js
@@ -0,0 +1,15 @@
+import { destroyFunc, updateFunc } from 'redux/nodes/entities/campaigns/helpers';
+import Kolide from 'kolide';
+import reduxConfig from 'redux/nodes/entities/base/reduxConfig';
+import schemas from 'redux/nodes/entities/base/schemas';
+
+const { CAMPAIGNS: schema } = schemas;
+
+export default reduxConfig({
+ createFunc: Kolide.runQuery,
+ destroyFunc,
+ updateFunc,
+ entityName: 'campaigns',
+ schema,
+});
+
diff --git a/frontend/redux/nodes/entities/campaigns/helpers.js b/frontend/redux/nodes/entities/campaigns/helpers.js
new file mode 100644
index 0000000000..4ec87a7f0e
--- /dev/null
+++ b/frontend/redux/nodes/entities/campaigns/helpers.js
@@ -0,0 +1,41 @@
+export const destroyFunc = (campaign) => {
+ return Promise.resolve(campaign);
+};
+
+export const updateFunc = (campaign, socketData) => {
+ return new Promise((resolve, reject) => {
+ const { type, data } = socketData;
+
+ if (type === 'totals') {
+ return resolve({
+ ...campaign,
+ totals: data,
+ });
+ }
+
+ if (type === 'result') {
+ const queryResults = campaign.query_results || [];
+ const hosts = campaign.hosts || [];
+ const { host, rows } = data;
+ const newQueryResults = rows.map((row) => {
+ return { ...row, hostname: host.hostname };
+ });
+
+ return resolve({
+ ...campaign,
+ hosts: [
+ ...hosts,
+ host,
+ ],
+ query_results: [
+ ...queryResults,
+ ...newQueryResults,
+ ],
+ });
+ }
+
+ return reject();
+ });
+};
+
+export default { destroyFunc, updateFunc };
diff --git a/frontend/redux/nodes/entities/campaigns/helpers.tests.js b/frontend/redux/nodes/entities/campaigns/helpers.tests.js
new file mode 100644
index 0000000000..6d27e0702b
--- /dev/null
+++ b/frontend/redux/nodes/entities/campaigns/helpers.tests.js
@@ -0,0 +1,104 @@
+import expect from 'expect';
+
+import helpers from './helpers';
+
+const host = {
+ hostname: 'jmeller-mbp.local',
+ id: 1,
+};
+const campaign = {
+ id: 4,
+ query_id: 12,
+ status: 0,
+ user_id: 1,
+};
+const campaignWithResults = {
+ ...campaign,
+ hosts: [{ id: 2, hostname: 'some-machine' }],
+ query_results: [
+ { host: 'some-machine', feature: 'vendor', value: 'GenuineIntel' },
+ ],
+ totals: {
+ count: 3,
+ online: 2,
+ },
+};
+const { destroyFunc, updateFunc } = helpers;
+const resultSocketData = {
+ type: 'result',
+ data: {
+ distributed_query_execution_id: 5,
+ host,
+ rows: [
+ { feature: 'product_name', value: 'Intel Core' },
+ { feature: 'family', value: '0600' },
+ ],
+ },
+};
+const totalsSocketData = {
+ type: 'totals',
+ data: {
+ count: 5,
+ online: 1,
+ },
+};
+
+describe('campaign entity - helpers', () => {
+ describe('#destroyFunc', () => {
+ it('returns the campaign', (done) => {
+ destroyFunc(campaign)
+ .then((response) => {
+ expect(response).toEqual(campaign);
+ done();
+ })
+ .catch(done);
+ });
+ });
+
+ describe('#updateFunc', () => {
+ it('appends query results to the campaign when the campaign has query results', (done) => {
+ updateFunc(campaignWithResults, resultSocketData)
+ .then((response) => {
+ expect(response.query_results).toEqual([
+ ...campaignWithResults.query_results,
+ { hostname: host.hostname, feature: 'product_name', value: 'Intel Core' },
+ { hostname: host.hostname, feature: 'family', value: '0600' },
+ ]);
+ expect(response.hosts).toInclude(host);
+ done();
+ })
+ .catch(done);
+ });
+
+ it('adds query results to the campaign when the campaign does not have query results', (done) => {
+ updateFunc(campaign, resultSocketData)
+ .then((response) => {
+ expect(response.query_results).toEqual([
+ { hostname: host.hostname, feature: 'product_name', value: 'Intel Core' },
+ { hostname: host.hostname, feature: 'family', value: '0600' },
+ ]);
+ expect(response.hosts).toInclude(host);
+ done();
+ })
+ .catch(done);
+ });
+
+ it('updates totals on the campaign when the campaign has totals', (done) => {
+ updateFunc(campaignWithResults, totalsSocketData)
+ .then((response) => {
+ expect(response.totals).toEqual(totalsSocketData.data);
+ done();
+ })
+ .catch(done);
+ });
+
+ it('adds totals to the campaign when the campaign does not have totals', (done) => {
+ updateFunc(campaign, totalsSocketData)
+ .then((response) => {
+ expect(response.totals).toEqual(totalsSocketData.data);
+ done();
+ })
+ .catch(done);
+ });
+ });
+});
diff --git a/frontend/redux/nodes/entities/campaigns/reducer.js b/frontend/redux/nodes/entities/campaigns/reducer.js
new file mode 100644
index 0000000000..93a1bd364b
--- /dev/null
+++ b/frontend/redux/nodes/entities/campaigns/reducer.js
@@ -0,0 +1,3 @@
+import config from './config';
+
+export default config.reducer;
diff --git a/frontend/redux/nodes/entities/reducer.js b/frontend/redux/nodes/entities/reducer.js
index efa5cc9f3b..8c39672700 100644
--- a/frontend/redux/nodes/entities/reducer.js
+++ b/frontend/redux/nodes/entities/reducer.js
@@ -1,5 +1,6 @@
import { combineReducers } from 'redux';
+import campaigns from './campaigns/reducer';
import hosts from './hosts/reducer';
import invites from './invites/reducer';
import labels from './labels/reducer';
@@ -8,6 +9,7 @@ import queries from './queries/reducer';
import users from './users/reducer';
export default combineReducers({
+ campaigns,
hosts,
invites,
labels,
diff --git a/frontend/test/mocks.js b/frontend/test/mocks.js
index e521a4ce1b..995356fdfa 100644
--- a/frontend/test/mocks.js
+++ b/frontend/test/mocks.js
@@ -214,6 +214,16 @@ export const invalidResetPasswordRequest = (password, token, error) => {
.reply(422, { error });
};
+export const validRunQueryRequest = (bearerToken, data) => {
+ return nock('http://localhost:8080', {
+ reqHeaders: {
+ Authorization: `Bearer ${bearerToken}`,
+ },
+ })
+ .post('/api/v1/kolide/queries/run', JSON.stringify(data))
+ .reply(200, { campaign: { id: 1 } });
+};
+
export const validSetupRequest = (formData) => {
const setupData = helpers.setupData(formData);
@@ -258,6 +268,7 @@ export default {
validMeRequest,
validResetPasswordRequest,
validRevokeInviteRequest,
+ validRunQueryRequest,
validSetupRequest,
validUpdateQueryRequest,
validUpdateUserRequest,
diff --git a/frontend/utilities/filter_array_by_hash/filter_array_by_hash.js b/frontend/utilities/filter_array_by_hash/filter_array_by_hash.js
new file mode 100644
index 0000000000..48f82101dc
--- /dev/null
+++ b/frontend/utilities/filter_array_by_hash/filter_array_by_hash.js
@@ -0,0 +1,22 @@
+import { every, filter, keys, pick } from 'lodash';
+
+const filterArrayByHash = (array, arrayFilter) => {
+ return filter(array, (obj) => {
+ const filterKeys = keys(arrayFilter);
+
+ return every(pick(obj, filterKeys), (val, key) => {
+ const arrayFilterValue = arrayFilter[key];
+
+ if (!arrayFilterValue) {
+ return true;
+ }
+
+ const lowerVal = val.toLowerCase();
+ const lowerArrayFilterValue = arrayFilterValue.toLowerCase();
+
+ return lowerVal.includes(lowerArrayFilterValue);
+ });
+ });
+};
+
+export default filterArrayByHash;
diff --git a/frontend/utilities/filter_array_by_hash/filter_array_by_hash.tests.js b/frontend/utilities/filter_array_by_hash/filter_array_by_hash.tests.js
new file mode 100644
index 0000000000..a859c2aa3d
--- /dev/null
+++ b/frontend/utilities/filter_array_by_hash/filter_array_by_hash.tests.js
@@ -0,0 +1,26 @@
+import expect from 'expect';
+
+import filterArrayByHash from 'utilities/filter_array_by_hash';
+
+describe('filterArrayByHash', () => {
+ const o1 = { foo: 'foo', bar: 'bar' };
+ const o2 = { foo: 'fooz', bar: 'barz' };
+ const array = [o1, o2];
+
+ it('filters the array to all objects that include the filter strings', () => {
+ const filter1 = { foo: 'foo', bar: 'bar' };
+ const filter2 = { foo: '', bar: 'bar' };
+ const filter3 = { foo: '' };
+ const filter4 = { foo: 'Fooz', bar: 'bar' };
+ const filter5 = {};
+ const filter6 = { bar: 'Foo' };
+
+ expect(filterArrayByHash(array, filter1)).toEqual(array);
+ expect(filterArrayByHash(array, filter2)).toEqual(array);
+ expect(filterArrayByHash(array, filter3)).toEqual(array);
+ expect(filterArrayByHash(array, filter4)).toEqual([o2]);
+ expect(filterArrayByHash(array, filter5)).toEqual(array);
+ expect(filterArrayByHash(array, filter6)).toEqual([]);
+ });
+});
+
diff --git a/frontend/utilities/filter_array_by_hash/index.js b/frontend/utilities/filter_array_by_hash/index.js
new file mode 100644
index 0000000000..ce33c509f5
--- /dev/null
+++ b/frontend/utilities/filter_array_by_hash/index.js
@@ -0,0 +1 @@
+export default from './filter_array_by_hash';