Allow users to export query results (#1082)

This commit is contained in:
Mike Stone 2017-01-24 16:18:02 -05:00 committed by Jason Meller
parent ac95b764eb
commit 8432d0494f
13 changed files with 236 additions and 87 deletions

View File

@ -15,13 +15,13 @@ class QueryResultsRow extends Component {
render () {
const { index, queryResult } = this.props;
const { hostname } = queryResult;
const queryAttrs = omit(queryResult, ['hostname']);
const { host_hostname: hostHostname } = queryResult;
const queryAttrs = omit(queryResult, ['host_hostname']);
const queryAttrValues = values(queryAttrs);
return (
<tr>
<td>{hostname}</td>
<td>{hostHostname}</td>
{queryAttrValues.map((attribute, i) => {
return <td key={`query-results-table-row-${index}-${i}`}>{attribute}</td>;
})}

View File

@ -1,7 +1,8 @@
import React, { Component } from 'react';
import React, { Component, PropTypes } from 'react';
import classnames from 'classnames';
import { get, keys, omit } from 'lodash';
import Button from 'components/buttons/Button';
import campaignInterface from 'interfaces/campaign';
import filterArrayByHash from 'utilities/filter_array_by_hash';
import Icon from 'components/icons/Icon';
@ -14,6 +15,7 @@ const baseClass = 'query-results-table';
class QueryResultsTable extends Component {
static propTypes = {
campaign: campaignInterface.isRequired,
onExportQueryResults: PropTypes.func,
};
constructor (props) {
@ -117,7 +119,7 @@ class QueryResultsTable extends Component {
}
render () {
const { campaign } = this.props;
const { campaign, onExportQueryResults } = this.props;
const {
renderProgressDetails,
renderTableHeaderRow,
@ -131,6 +133,13 @@ class QueryResultsTable extends Component {
return (
<div className={baseClass}>
<Button
className={`${baseClass}__export-btn`}
onClick={onExportQueryResults}
variant="brand"
>
Export
</Button>
{renderProgressDetails()}
<div className={`${baseClass}__table-wrapper`}>
<table className={`${baseClass}__table`}>

View File

@ -1,5 +1,5 @@
import React from 'react';
import expect from 'expect';
import expect, { createSpy, restoreSpies } from 'expect';
import { keys } from 'lodash';
import { mount } from 'enzyme';
@ -48,6 +48,8 @@ const campaignWithQueryResults = {
};
describe('QueryResultsTable - component', () => {
afterEach(restoreSpies);
const componentWithoutQueryResults = mount(
<QueryResultsTable campaign={campaignWithNoQueryResults} />
);
@ -78,4 +80,17 @@ describe('QueryResultsTable - component', () => {
expect(tableHeaderText).toInclude(key);
});
});
it('calls the onExportQueryResults prop when the export button is clicked', () => {
const spy = createSpy();
const component = mount(<QueryResultsTable campaign={campaignWithQueryResults} onExportQueryResults={spy} />);
const exportBtn = component.find('Button');
expect(spy).toNotHaveBeenCalled();
exportBtn.simulate('click');
expect(spy).toHaveBeenCalled();
});
});

View File

@ -4,6 +4,10 @@
max-width: 100%;
box-sizing: border-box;
&__export-btn {
float: right;
}
&__filter-icon {
&--is-active {
color: $brand;
@ -20,6 +24,7 @@
border-radius: 3px;
box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12);
overflow: scroll;
margin-top: 20px;
max-height: 550px;
}

View File

@ -63,7 +63,8 @@ class Kolide extends Base {
getCounts: () => {
const { STATUS_LABEL_COUNTS } = endpoints;
return this.authenticatedGet(this.endpoint(STATUS_LABEL_COUNTS));
return this.authenticatedGet(this.endpoint(STATUS_LABEL_COUNTS))
.catch(() => false);
},
}
@ -76,6 +77,15 @@ class Kolide extends Base {
},
}
queries = {
run: ({ query, selected }) => {
const { RUN_QUERY } = endpoints;
return this.authenticatedPost(this.endpoint(RUN_QUERY), JSON.stringify({ query, selected }))
.then(response => response.campaign);
},
}
users = {
changePassword: (passwordParams) => {
const { CHANGE_PASSWORD } = endpoints;
@ -104,6 +114,22 @@ class Kolide extends Base {
},
}
websockets = {
queries: {
run: (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);
});
},
},
}
createLabel = ({ description, name, query }) => {
const { LABELS } = endpoints;
@ -405,25 +431,6 @@ 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);

View File

@ -296,17 +296,19 @@ describe('Kolide - API client', () => {
.catch(done);
});
it('#runQuery', (done) => {
const data = { query: 'select * from users', selected: { hosts: [], labels: [] } };
const request = validRunQueryRequest(bearerToken, data);
describe('#run', () => {
it('calls the correct endpoint with the correct params', (done) => {
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);
Kolide.setBearerToken(bearerToken);
Kolide.queries.run(data)
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
});
});

View File

@ -1,12 +1,14 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import { first, filter, includes, isArray, isEqual, values } from 'lodash';
import classnames from 'classnames';
import { connect } from 'react-redux';
import FileSaver from 'file-saver';
import { filter, includes, isArray, isEqual, size } from 'lodash';
import moment from 'moment';
import { push } from 'react-router-redux';
import Kolide from 'kolide';
import campaignActions from 'redux/nodes/entities/campaigns/actions';
import campaignInterface from 'interfaces/campaign';
import campaignHelpers from 'redux/nodes/entities/campaigns/helpers';
import convertToCSV from 'utilities/convert_to_csv';
import debounce from 'utilities/debounce';
import deepDifference from 'utilities/deep_difference';
import entityGetter from 'redux/utilities/entityGetter';
@ -26,9 +28,8 @@ import Spinner from 'components/loaders/Spinner';
const baseClass = 'query-page';
class QueryPage extends Component {
export class QueryPage extends Component {
static propTypes = {
campaign: campaignInterface,
dispatch: PropTypes.func,
errors: PropTypes.shape({
base: PropTypes.string,
@ -43,10 +44,13 @@ class QueryPage extends Component {
super(props);
this.state = {
campaign: {},
queryIsRunning: false,
targetsCount: 0,
targetsError: null,
};
this.csvQueryName = 'Query Results';
}
componentWillMount () {
@ -68,6 +72,38 @@ class QueryPage extends Component {
return false;
}
onChangeQueryFormField = (fieldName, value) => {
if (fieldName === 'name') {
this.csvQueryName = value;
}
return false;
}
onExportQueryResults = (evt) => {
evt.preventDefault();
const { campaign } = this.state;
const { query_results: queryResults } = campaign;
if (queryResults) {
const csv = convertToCSV(queryResults, (fields) => {
const result = filter(fields, f => f !== 'host_hostname');
result.unshift('host_hostname');
return result;
});
const formattedTime = moment(new Date()).format('MM-DD-YY hh-mm-ss');
const filename = `${this.csvQueryName} (${formattedTime}).csv`;
const file = new global.window.File([csv], filename, { type: 'text/csv' });
FileSaver.saveAs(file);
}
return false;
}
onFetchTargets = (query, targetResponse) => {
const { dispatch } = this.props;
const {
@ -104,18 +140,17 @@ class QueryPage extends Component {
return false;
}
const { create, update } = campaignActions;
const { destroyCampaign, removeSocket } = this;
const selected = formatSelectedTargetsForApi(selectedTargets);
removeSocket();
destroyCampaign();
dispatch(create({ query: queryText, selected }))
Kolide.queries.run({ query: queryText, selected })
.then((campaignResponse) => {
return Kolide.runQueryWebsocket(campaignResponse.id)
return Kolide.websockets.queries.run(campaignResponse.id)
.then((socket) => {
this.campaign = campaignResponse;
this.setState({ campaign: campaignResponse });
this.socket = socket;
this.setState({ queryIsRunning: true });
@ -129,10 +164,21 @@ class QueryPage extends Component {
return false;
}
return dispatch(update(this.campaign, socketData))
return campaignHelpers.update(this.state.campaign, socketData)
.then((updatedCampaign) => {
const { status } = updatedCampaign;
if (status === 'finished') {
this.setState({ queryIsRunning: false });
removeSocket();
return false;
}
this.previousSocketData = socketData;
this.campaign = updatedCampaign;
this.setState({ campaign: updatedCampaign });
return false;
});
};
});
@ -203,12 +249,11 @@ class QueryPage extends Component {
};
destroyCampaign = () => {
const { campaign, dispatch } = this.props;
const { destroy } = campaignActions;
const { campaign } = this.state;
if (campaign) {
if (this.campaign || campaign) {
this.campaign = null;
dispatch(destroy(campaign));
this.setState({ campaign: {} });
}
return false;
@ -225,20 +270,21 @@ class QueryPage extends Component {
}
renderResultsTable = () => {
const { campaign } = this.props;
const { campaign } = this.state;
const { onExportQueryResults } = this;
const resultsClasses = classnames(`${baseClass}__results`, 'body-wrap', {
[`${baseClass}__results--loading`]: this.socket && !campaign.query_results,
});
let resultBody = '';
if (!campaign || (!this.socket && !campaign.query_results)) {
if (!size(campaign) || (!this.socket && !campaign.query_results)) {
return false;
}
if ((!campaign.query_results || campaign.query_results.length < 1) && this.socket) {
resultBody = <Spinner />;
} else {
resultBody = <QueryResultsTable campaign={campaign} />;
resultBody = <QueryResultsTable campaign={campaign} onExportQueryResults={onExportQueryResults} />;
}
return (
@ -250,6 +296,7 @@ class QueryPage extends Component {
render () {
const {
onChangeQueryFormField,
onFetchTargets,
onOsqueryTableSelect,
onRunQuery,
@ -275,6 +322,7 @@ class QueryPage extends Component {
<QueryForm
formData={query}
handleSubmit={onSaveQueryFormSubmit}
onChangeFunc={onChangeQueryFormField}
onFetchTargets={onFetchTargets}
onOsqueryTableSelect={onOsqueryTableSelect}
onRunQuery={onRunQuery}
@ -304,10 +352,8 @@ class QueryPage extends Component {
const mapStateToProps = (state, ownProps) => {
const stateEntities = entityGetter(state);
const { id: queryID } = ownProps.params;
const { entities: campaigns } = stateEntities.get('campaigns');
const reduxQuery = entityGetter(state).get('queries').findBy({ id: queryID });
const { queryText, selectedOsqueryTable } = state.components.QueryPages;
const campaign = first(values(campaigns));
const { errors } = state.entities.queries;
const queryStub = { description: '', name: '', query: queryText };
const query = reduxQuery || queryStub;
@ -329,7 +375,6 @@ const mapStateToProps = (state, ownProps) => {
}
return {
campaign,
errors,
hostIDs,
query,

View File

@ -1,11 +1,15 @@
import React from 'react';
import expect, { spyOn, restoreSpies } from 'expect';
import FileSave from 'file-saver';
import { mount } from 'enzyme';
import { noop } from 'lodash';
import convertToCSV from 'utilities/convert_to_csv';
import { defaultSelectedOsqueryTable } from 'redux/nodes/components/QueryPages/actions';
import helpers from 'test/helpers';
import kolide from 'kolide';
import queryActions from 'redux/nodes/entities/queries/actions';
import QueryPage from 'pages/queries/QueryPage';
import ConnectedQueryPage, { QueryPage } from 'pages/queries/QueryPage/QueryPage';
import { validUpdateQueryRequest } from 'test/mocks';
import { hostStub } from 'test/stubs';
@ -37,13 +41,13 @@ describe('QueryPage - component', () => {
});
it('renders the QueryForm component', () => {
const page = mount(connectedComponent(QueryPage, { mockStore, props: locationProp }));
const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp }));
expect(page.find('QueryForm').length).toEqual(1);
});
it('renders the QuerySidePanel component', () => {
const page = mount(connectedComponent(QueryPage, { mockStore, props: locationProp }));
const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp }));
expect(page.find('QuerySidePanel').length).toEqual(1);
});
@ -51,15 +55,15 @@ describe('QueryPage - component', () => {
it('sets selectedTargets based on host_ids', () => {
const singleHostProps = { params: {}, location: { query: { host_ids: String(hostStub.id) } } };
const multipleHostsProps = { params: {}, location: { query: { host_ids: [String(hostStub.id), '99'] } } };
const singleHostPage = mount(connectedComponent(QueryPage, { mockStore, props: singleHostProps }));
const multipleHostsPage = mount(connectedComponent(QueryPage, { mockStore, props: multipleHostsProps }));
const singleHostPage = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: singleHostProps }));
const multipleHostsPage = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: multipleHostsProps }));
expect(singleHostPage.find('QueryPage').prop('selectedTargets')).toEqual([hostStub]);
expect(multipleHostsPage.find('QueryPage').prop('selectedTargets')).toEqual([hostStub, { ...hostStub, id: 99 }]);
});
it('sets targetError in state when the query is run and there are no selected targets', () => {
const page = mount(connectedComponent(QueryPage, { mockStore, props: locationProp }));
const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp }));
const form = page.find('QueryForm');
const runQueryBtn = form.find('.query-form__run-query-btn');
@ -91,7 +95,7 @@ describe('QueryPage - component', () => {
},
},
});
const page = mount(connectedComponent(QueryPage, {
const page = mount(connectedComponent(ConnectedQueryPage, {
mockStore: mockStoreWithQuery,
props: locationWithQueryProp,
}));
@ -115,4 +119,25 @@ describe('QueryPage - component', () => {
type: 'queries_UPDATE_REQUEST',
});
});
describe('export as csv', () => {
it('exports the campaign query results in csv format', () => {
const queryResult = { org_name: 'Kolide', org_url: 'https://kolide.co' };
const campaign = { id: 1, query_results: [queryResult] };
const queryResultsCSV = convertToCSV([queryResult]);
const fileSaveSpy = spyOn(FileSave, 'saveAs');
const Page = mount(<QueryPage dispatch={noop} selectedOsqueryTable={defaultSelectedOsqueryTable} />);
const filename = 'query_results.csv';
const fileStub = new global.window.File([queryResultsCSV], filename, { type: 'text/csv' });
Page.setState({ campaign });
Page.node.socket = {};
const QueryResultsTable = Page.find('QueryResultsTable');
QueryResultsTable.find('Button').simulate('click');
expect(fileSaveSpy).toHaveBeenCalledWith(fileStub);
});
});
});

View File

@ -1,4 +1,4 @@
import { destroyFunc, updateFunc } from 'redux/nodes/entities/campaigns/helpers';
import { destroyFunc, update } 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';
@ -6,9 +6,9 @@ import schemas from 'redux/nodes/entities/base/schemas';
const { CAMPAIGNS: schema } = schemas;
export default reduxConfig({
createFunc: Kolide.runQuery,
createFunc: Kolide.queries.run,
destroyFunc,
updateFunc,
updateFunc: update,
entityName: 'campaigns',
schema,
});

View File

@ -2,8 +2,8 @@ export const destroyFunc = (campaign) => {
return Promise.resolve(campaign);
};
export const updateFunc = (campaign, socketData) => {
return new Promise((resolve, reject) => {
export const update = (campaign, socketData) => {
return new Promise((resolve) => {
const { type, data } = socketData;
if (type === 'totals') {
@ -17,9 +17,6 @@ export const updateFunc = (campaign, socketData) => {
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,
@ -29,13 +26,19 @@ export const updateFunc = (campaign, socketData) => {
],
query_results: [
...queryResults,
...newQueryResults,
...rows,
],
});
}
return reject();
if (type === 'status') {
const { status } = data;
return resolve({ ...campaign, status });
}
return resolve(campaign);
});
};
export default { destroyFunc, updateFunc };
export default { destroyFunc, update };

View File

@ -23,7 +23,7 @@ const campaignWithResults = {
online: 2,
},
};
const { destroyFunc, updateFunc } = helpers;
const { destroyFunc, update } = helpers;
const resultSocketData = {
type: 'result',
data: {
@ -55,14 +55,14 @@ describe('campaign entity - helpers', () => {
});
});
describe('#updateFunc', () => {
describe('#update', () => {
it('appends query results to the campaign when the campaign has query results', (done) => {
updateFunc(campaignWithResults, resultSocketData)
update(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' },
{ feature: 'product_name', value: 'Intel Core' },
{ feature: 'family', value: '0600' },
]);
expect(response.hosts).toInclude(host);
done();
@ -71,11 +71,11 @@ describe('campaign entity - helpers', () => {
});
it('adds query results to the campaign when the campaign does not have query results', (done) => {
updateFunc(campaign, resultSocketData)
update(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' },
{ feature: 'product_name', value: 'Intel Core' },
{ feature: 'family', value: '0600' },
]);
expect(response.hosts).toInclude(host);
done();
@ -84,7 +84,7 @@ describe('campaign entity - helpers', () => {
});
it('updates totals on the campaign when the campaign has totals', (done) => {
updateFunc(campaignWithResults, totalsSocketData)
update(campaignWithResults, totalsSocketData)
.then((response) => {
expect(response.totals).toEqual(totalsSocketData.data);
done();
@ -93,7 +93,7 @@ describe('campaign entity - helpers', () => {
});
it('adds totals to the campaign when the campaign does not have totals', (done) => {
updateFunc(campaign, totalsSocketData)
update(campaign, totalsSocketData)
.then((response) => {
expect(response.totals).toEqual(totalsSocketData.data);
done();

View File

@ -0,0 +1,20 @@
import expect from 'expect';
import convertToCSV from 'utilities/convert_to_csv';
const objArray = [
{
first_name: 'Mike',
last_name: 'Stone',
},
{
first_name: 'Paul',
last_name: 'Simon',
},
];
describe('convertToCSV - utility', () => {
it('converts an array of objects to CSV format', () => {
expect(convertToCSV(objArray)).toEqual('"first_name","last_name"\n"Mike","Stone"\n"Paul","Simon"');
});
});

View File

@ -0,0 +1,18 @@
import { keys } from 'lodash';
const defaultFieldSortFunc = fields => fields;
const convertToCSV = (objArray, fieldSortFunc = defaultFieldSortFunc) => {
const fields = fieldSortFunc(keys(objArray[0]));
const jsonFields = fields.map(field => JSON.stringify(field));
const rows = objArray.map((row) => {
return fields.map(field => JSON.stringify(row[field])).join(',');
});
rows.unshift(jsonFields.join(','));
return rows.join('\n');
};
export default convertToCSV;