* Adds campaigns to redux state

* Update campaign with web socket data

* Destroy the current campaign when creating a new one

* close the socket when leaving the page or creating a new campaign

* Allow stopping a running query

* Update campaign with query results

* Adds QueryResultsTable

* Display flash message if campaign can't be created

* Allow filtering query results

* Adds filter icon

* Prevent query text updates when the query is running
This commit is contained in:
Mike Stone 2016-12-21 12:07:13 -05:00 коммит произвёл GitHub
Родитель 11a5104d2c
Коммит 8567cc458c
32 изменённых файлов: 790 добавлений и 63 удалений

Просмотреть файл

@ -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 {
<input
name={name}
onChange={onInputChange}
onFocus={onFocus}
className={inputClasses}
placeholder={placeholder}
ref={(r) => { this.input = r; }}

Просмотреть файл

@ -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';

Просмотреть файл

@ -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 };

Просмотреть файл

@ -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],
});
});
});
});

Просмотреть файл

@ -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 = (
<Button
className={`${baseClass}__stop-query-btn`}
onClick={onStopQuery}
text="Stop Query"
variant="alert"
/>
);
} else {
runQueryButton = (
<Button
className={`${baseClass}__run-query-btn`}
onClick={onRunQuery}
text="Run Query"
variant="brand"
/>
);
}
if (queryType === 'label') {
return (
@ -202,12 +231,7 @@ class QueryForm extends Component {
text="Save As New..."
variant="success"
/>
<Button
className={`${baseClass}__run-query-btn`}
onClick={onRunQuery}
text="Run Query"
variant="brand"
/>
{runQueryButton}
</div>
);
}

Просмотреть файл

@ -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(<QueryForm query={query} queryIsRunning queryText={queryText} />);
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(<QueryForm query={query} queryIsRunning={false} queryText={queryText} />);
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(
<QueryForm onStopQuery={onStopQuerySpy} query={query} queryIsRunning queryText={queryText} />
);
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(<QueryForm query={query} queryText={queryText} />);
const inputFields = form.find('InputField');

Просмотреть файл

@ -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}

Просмотреть файл

@ -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 (
<div className={`${baseClass}__progress-details`}>
<span>
<b>{totalHostsReturned}</b>&nbsp;of&nbsp;
<b>{totalHostsCount} Hosts</b>&nbsp;Returning&nbsp;
<b>{totalRowsCount} Records</b>
</span>
<ProgressBar max={totalHostsCount} value={totalHostsReturned} />
</div>
);
}
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 (
<th key={`query-results-table-header-${index}`}>
<span><Icon className={filterIconClassName} name="filter" />{column}</span>
<InputField
name={column}
onChange={onFilterAttribute(column)}
onFocus={onSetActiveColumn(column)}
value={resultsFilter[column]}
/>
</th>
);
}
renderTableHeaderRow = () => {
const { campaign } = this.props;
const { renderTableHeaderRowData } = this;
const { query_results: queryResults } = campaign;
const queryAttrs = omit(queryResults[0], ['hostname']);
const queryResultColumns = keys(queryAttrs);
return (
<tr>
{renderTableHeaderRowData('hostname', -1)}
{queryResultColumns.map((column, i) => {
return renderTableHeaderRowData(column, i);
})}
</tr>
);
}
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 (
<tr key={`query-results-table-row-${index}`}>
<td>{row.hostname}</td>
{queryResult.map((attribute, i) => {
return <td key={`query-results-table-row-data-${i}`}>{attribute}</td>;
})}
</tr>
);
});
}
render () {
const { campaign } = this.props;
const {
renderProgressDetails,
renderTableHeaderRow,
renderTableRows,
} = this;
const { query_results: queryResults } = campaign;
if (!queryResults || !queryResults.length) {
return false;
}
return (
<div className={baseClass}>
{renderProgressDetails()}
<div className={`${baseClass}__table-wrapper`}>
<table className={`${baseClass}__table`}>
<thead>
{renderTableHeaderRow()}
</thead>
<tbody>
{renderTableRows()}
</tbody>
</table>
</div>
</div>
);
}
}
export default QueryResultsTable;

Просмотреть файл

@ -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(
<QueryResultsTable campaign={campaignWithNoQueryResults} />
);
const componentWithQueryResults = mount(
<QueryResultsTable campaign={campaignWithQueryResults} />
);
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);
});
});
});

Просмотреть файл

@ -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;
}
}
}

Просмотреть файл

@ -0,0 +1 @@
export default from './QueryResultsTable';

Просмотреть файл

@ -0,0 +1,7 @@
import { PropTypes } from 'react';
export default PropTypes.shape({
count: PropTypes.number,
id: PropTypes.number,
online: PropTypes.number,
});

Просмотреть файл

@ -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');
}

Просмотреть файл

@ -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',

Просмотреть файл

@ -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 };

Просмотреть файл

@ -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',

Просмотреть файл

@ -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);

Просмотреть файл

@ -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 = {

Просмотреть файл

@ -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'));
});

Просмотреть файл

@ -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 && <QueryResultsTable campaign={campaign} />}
<QuerySidePanel
onOsqueryTableSelect={onOsqueryTableSelect}
onTextEditorInputChange={onTextEditorInputChange}
@ -187,10 +284,12 @@ class QueryPage extends Component {
const mapStateToProps = (state, { params }) => {
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);

Просмотреть файл

@ -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;

Просмотреть файл

@ -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,

Просмотреть файл

@ -0,0 +1,3 @@
import config from './config';
export default config.actions;

Просмотреть файл

@ -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,
});

Просмотреть файл

@ -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 };

Просмотреть файл

@ -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);
});
});
});

Просмотреть файл

@ -0,0 +1,3 @@
import config from './config';
export default config.reducer;

Просмотреть файл

@ -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,

Просмотреть файл

@ -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,

Просмотреть файл

@ -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;

Просмотреть файл

@ -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([]);
});
});

Просмотреть файл

@ -0,0 +1 @@
export default from './filter_array_by_hash';