зеркало из https://github.com/mozilla/fleet.git
Run query (#549)
* 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:
Родитель
11a5104d2c
Коммит
8567cc458c
|
@ -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> of
|
||||
<b>{totalHostsCount} Hosts</b> Returning
|
||||
<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';
|
Загрузка…
Ссылка в новой задаче