New query page updates (#1229)

* Stop rendering results when query hasn’t been run

* Adds QueryPageSelectTargets component

* Re-arranges target select input on Query Page

* Adds label to KolideAce component

* Re-arrange inputs on the Query Form component
This commit is contained in:
Mike Stone 2017-02-16 15:31:21 -05:00 committed by GitHub
parent 146ee18c62
commit 803bc41366
17 changed files with 464 additions and 205 deletions

View File

@ -14,6 +14,7 @@ class KolideAce extends Component {
static propTypes = { static propTypes = {
error: PropTypes.string, error: PropTypes.string,
fontSize: PropTypes.number, fontSize: PropTypes.number,
label: PropTypes.string,
name: PropTypes.string, name: PropTypes.string,
onChange: PropTypes.func, onChange: PropTypes.func,
onLoad: PropTypes.func, onLoad: PropTypes.func,
@ -31,6 +32,18 @@ class KolideAce extends Component {
wrapEnabled: false, wrapEnabled: false,
}; };
renderLabel = () => {
const { error, label } = this.props;
const labelClassName = classnames(`${baseClass}__label`, {
[`${baseClass}__label--error`]: error,
});
return (
<p className={labelClassName}>{error || label}</p>
);
}
render () { render () {
const { const {
error, error,
@ -44,6 +57,7 @@ class KolideAce extends Component {
wrapEnabled, wrapEnabled,
wrapperClassName, wrapperClassName,
} = this.props; } = this.props;
const { renderLabel } = this;
const wrapperClass = classnames(wrapperClassName, { const wrapperClass = classnames(wrapperClassName, {
[`${baseClass}__wrapper--error`]: error, [`${baseClass}__wrapper--error`]: error,
@ -51,7 +65,7 @@ class KolideAce extends Component {
return ( return (
<div className={wrapperClass}> <div className={wrapperClass}>
<div className={`${baseClass}__error-field`}>{error}</div> {renderLabel()}
<AceEditor <AceEditor
enableBasicAutocompletion enableBasicAutocompletion
enableLiveAutocompletion enableLiveAutocompletion

View File

@ -1,14 +1,18 @@
.kolide-ace { .kolide-ace {
&__error-field { &__label {
color: $alert;
display: block;
font-size: 16px; font-size: 16px;
font-stretch: normal;
font-style: normal;
font-weight: $bold; font-weight: $bold;
font-style: normal;
font-stretch: normal;
letter-spacing: -0.5px; letter-spacing: -0.5px;
color: $text-dark;
display: block;
margin-bottom: 4px; margin-bottom: 4px;
min-height: 25px; min-height: 25px;
&--error {
color: $alert;
}
} }
&__wrapper { &__wrapper {

View File

@ -75,6 +75,10 @@ $base-class: 'button';
@include button-variant($warning); @include button-variant($warning);
} }
&--link {
@include button-variant($link);
}
&--inverse { &--inverse {
@include button-variant($white, $inverse-hover, $brand); @include button-variant($white, $inverse-hover, $brand);
color: $brand; color: $brand;

View File

@ -1,7 +1,6 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { size } from 'lodash'; import { size } from 'lodash';
import Button from 'components/buttons/Button';
import DropdownButton from 'components/buttons/DropdownButton'; import DropdownButton from 'components/buttons/DropdownButton';
import Form from 'components/forms/Form'; import Form from 'components/forms/Form';
import formFieldInterface from 'interfaces/form_field'; import formFieldInterface from 'interfaces/form_field';
@ -9,10 +8,7 @@ import helpers from 'components/forms/queries/QueryForm/helpers';
import InputField from 'components/forms/fields/InputField'; import InputField from 'components/forms/fields/InputField';
import KolideAce from 'components/KolideAce'; import KolideAce from 'components/KolideAce';
import queryInterface from 'interfaces/query'; import queryInterface from 'interfaces/query';
import SelectTargetsDropdown from 'components/forms/fields/SelectTargetsDropdown';
import targetInterface from 'interfaces/target';
import validateQuery from 'components/forms/validators/validate_query'; import validateQuery from 'components/forms/validators/validate_query';
import Timer from 'components/loaders/Timer';
const baseClass = 'query-form'; const baseClass = 'query-form';
@ -46,16 +42,9 @@ class QueryForm extends Component {
}).isRequired, }).isRequired,
handleSubmit: PropTypes.func, handleSubmit: PropTypes.func,
formData: queryInterface, formData: queryInterface,
onFetchTargets: PropTypes.func,
onOsqueryTableSelect: PropTypes.func, onOsqueryTableSelect: PropTypes.func,
onRunQuery: PropTypes.func,
onStopQuery: PropTypes.func,
onTargetSelect: PropTypes.func,
onUpdate: PropTypes.func, onUpdate: PropTypes.func,
queryIsRunning: PropTypes.bool, queryIsRunning: PropTypes.bool,
selectedTargets: PropTypes.arrayOf(targetInterface),
targetsCount: PropTypes.number,
targetsError: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@ -85,16 +74,6 @@ class QueryForm extends Component {
}); });
} }
onRunQuery = (queryText) => {
return (evt) => {
evt.preventDefault();
const { onRunQuery: handleRunQuery } = this.props;
return handleRunQuery(queryText);
};
}
onUpdate = (evt) => { onUpdate = (evt) => {
evt.preventDefault(); evt.preventDefault();
@ -126,113 +105,58 @@ class QueryForm extends Component {
renderButtons = () => { renderButtons = () => {
const { canSaveAsNew, canSaveChanges } = helpers; const { canSaveAsNew, canSaveChanges } = helpers;
const { const { fields, formData, handleSubmit } = this.props;
fields, const { onUpdate } = this;
formData,
handleSubmit,
onStopQuery,
queryIsRunning,
} = this.props;
const { onRunQuery, onUpdate } = this;
const dropdownBtnOptions = [{ const dropdownBtnOptions = [
disabled: !canSaveChanges(fields, formData), {
label: 'Save Changes', disabled: !canSaveChanges(fields, formData),
onClick: onUpdate, label: 'Save Changes',
}, { onClick: onUpdate,
disabled: !canSaveAsNew(fields, formData), },
label: 'Save As New...', {
onClick: handleSubmit, disabled: !canSaveAsNew(fields, formData),
}]; label: 'Save As New...',
onClick: handleSubmit,
let runQueryButton; },
];
if (queryIsRunning) {
runQueryButton = (
<Button
className={`${baseClass}__stop-query-btn`}
onClick={onStopQuery}
variant="alert"
>
Stop Query
</Button>
);
} else {
runQueryButton = (
<Button
className={`${baseClass}__run-query-btn`}
onClick={onRunQuery(fields.query.value)}
variant="brand"
>
Run Query
</Button>
);
}
return ( return (
<div className={`${baseClass}__button-wrap`}> <div className={`${baseClass}__button-wrap`}>
{queryIsRunning && <Timer running={queryIsRunning} />}
<DropdownButton <DropdownButton
className={`${baseClass}__save`} className={`${baseClass}__save`}
options={dropdownBtnOptions} options={dropdownBtnOptions}
variant="success" variant="brand"
> >
Save Save
</DropdownButton> </DropdownButton>
{runQueryButton}
</div>
);
}
renderTargetsInput = () => {
const {
onFetchTargets,
onTargetSelect,
selectedTargets,
targetsCount,
targetsError,
} = this.props;
return (
<div>
<SelectTargetsDropdown
error={targetsError}
onFetchTargets={onFetchTargets}
onSelect={onTargetSelect}
selectedTargets={selectedTargets}
targetsCount={targetsCount}
label="Select Targets"
/>
</div> </div>
); );
} }
render () { render () {
const { errors } = this.state;
const { baseError, fields, handleSubmit, queryIsRunning } = this.props; const { baseError, fields, handleSubmit, queryIsRunning } = this.props;
const { onLoad, renderButtons, renderTargetsInput } = this; const { errors } = this.state;
const { onLoad, renderButtons } = this;
return ( return (
<form className={`${baseClass}__wrapper`} onSubmit={handleSubmit}> <form className={`${baseClass}__wrapper`} onSubmit={handleSubmit}>
<h1>New Query</h1> <h1>New Query</h1>
<KolideAce
{...fields.query}
error={fields.query.error || errors.query}
onLoad={onLoad}
readOnly={queryIsRunning}
wrapperClassName={`${baseClass}__text-editor-wrapper`}
/>
{baseError && <div className="form__base-error">{baseError}</div>} {baseError && <div className="form__base-error">{baseError}</div>}
{renderTargetsInput()}
<InputField <InputField
{...fields.name} {...fields.name}
error={fields.name.error || errors.name} error={fields.name.error || errors.name}
inputClassName={`${baseClass}__query-title`} inputClassName={`${baseClass}__query-title`}
label="Query Title" label="Query Title"
/> />
<KolideAce
{...fields.query}
error={fields.query.error || errors.query}
label="SQL"
onLoad={onLoad}
readOnly={queryIsRunning}
wrapperClassName={`${baseClass}__text-editor-wrapper`}
/>
<InputField <InputField
{...fields.description} {...fields.description}
inputClassName={`${baseClass}__query-description`} inputClassName={`${baseClass}__query-description`}

View File

@ -37,36 +37,6 @@ describe('QueryForm - component', () => {
expect(inputFields.find({ name: 'description' }).length).toEqual(1); expect(inputFields.find({ name: 'description' }).length).toEqual(1);
}); });
it('renders a "stop query" button when a query is running', () => {
const form = mount(<QueryForm onTargetSelect={noop} 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 formData={{ ...query, query: queryText }} onTargetSelect={noop} queryIsRunning={false} />);
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} onTargetSelect={noop} formData={query} queryIsRunning queryText={queryText} />
);
const stopQueryBtn = form.find('.query-form__stop-query-btn');
stopQueryBtn.simulate('click');
expect(onStopQuerySpy).toHaveBeenCalled();
});
it('validates the query name before saving changes', () => { it('validates the query name before saving changes', () => {
const updateSpy = createSpy(); const updateSpy = createSpy();
const form = mount(<QueryForm formData={{ ...query, query: queryText }} onTargetSelect={noop} onUpdate={updateSpy} />); const form = mount(<QueryForm formData={{ ...query, query: queryText }} onTargetSelect={noop} onUpdate={updateSpy} />);
@ -175,14 +145,4 @@ describe('QueryForm - component', () => {
}, },
}); });
}); });
it('calls the onRunQuery prop with the query text when "Run Query" is clicked and the form is valid', () => {
const onRunQuerySpy = createSpy();
const form = mount(<QueryForm formData={{ ...query, query: queryText }} onRunQuery={onRunQuerySpy} onTargetSelect={noop} />);
const runQueryBtn = form.find('.query-form__run-query-btn');
runQueryBtn.simulate('click');
expect(onRunQuerySpy).toHaveBeenCalledWith(query.query);
});
}); });

View File

@ -1,6 +1,10 @@
.query-form { .query-form {
&__wrapper { &__wrapper {
padding: $base; padding: $base;
h1 {
margin-bottom: 19px;
}
} }
&__query-title, &__query-title,

View File

@ -0,0 +1,124 @@
import React, { Component, PropTypes } from 'react';
import { get } from 'lodash';
import Button from 'components/buttons/Button';
import campaignInterface from 'interfaces/campaign';
import ProgressBar from 'components/loaders/ProgressBar';
import SelectTargetsDropdown from 'components/forms/fields/SelectTargetsDropdown';
import targetInterface from 'interfaces/target';
import Timer from 'components/loaders/Timer';
const baseClass = 'query-page-select-targets';
class QueryPageSelectTargets extends Component {
static propTypes = {
campaign: campaignInterface,
error: PropTypes.string,
onFetchTargets: PropTypes.func.isRequired,
onRunQuery: PropTypes.func.isRequired,
onStopQuery: PropTypes.func.isRequired,
onTargetSelect: PropTypes.func.isRequired,
query: PropTypes.string,
queryIsRunning: PropTypes.bool,
selectedTargets: PropTypes.arrayOf(targetInterface),
targetsCount: PropTypes.number,
};
onRunQuery = () => {
const { onRunQuery, query } = this.props;
return onRunQuery(query);
}
renderProgressDetails = () => {
const {
campaign,
onStopQuery,
queryIsRunning,
} = this.props;
const { onRunQuery } = this;
const { hosts_count: hostsCount } = campaign;
const totalHostsCount = get(campaign, ['totals', 'count'], 0);
const totalRowsCount = get(campaign, ['query_results', 'length'], 0);
const runQueryBtn = (
<div className={`${baseClass}__query-btn-wrapper`}>
{queryIsRunning && <Timer running={queryIsRunning} />}
<Button
className={`${baseClass}__run-query-btn`}
onClick={onRunQuery}
variant="success"
>
Run
</Button>
</div>
);
const stopQueryBtn = (
<div className={`${baseClass}__query-btn-wrapper`}>
{queryIsRunning && <Timer running={queryIsRunning} />}
<Button
className={`${baseClass}__stop-query-btn`}
onClick={onStopQuery}
variant="alert"
>
Stop
</Button>
</div>
);
if (!hostsCount.total) {
return (
<div className={`${baseClass}__progress-wrapper`}>
<div className={`${baseClass}__progress-details`} />
{queryIsRunning ? stopQueryBtn : runQueryBtn}
</div>
);
}
return (
<div className={`${baseClass}__progress-wrapper`}>
<div className={`${baseClass}__progress-details`}>
<span>
<b>{hostsCount.total}</b>&nbsp;of&nbsp;
<b>{totalHostsCount} Hosts</b>&nbsp;Returning&nbsp;
<b>{totalRowsCount} Records&nbsp;</b>
({hostsCount.failed} failed)
</span>
<ProgressBar
error={hostsCount.failed}
max={totalHostsCount}
success={hostsCount.successful}
/>
</div>
{queryIsRunning ? stopQueryBtn : runQueryBtn}
</div>
);
}
render () {
const {
error,
onFetchTargets,
onTargetSelect,
selectedTargets,
targetsCount,
} = this.props;
const { renderProgressDetails } = this;
return (
<div className={`${baseClass}__wrapper body-wrap`}>
{renderProgressDetails()}
<SelectTargetsDropdown
error={error}
onFetchTargets={onFetchTargets}
onSelect={onTargetSelect}
selectedTargets={selectedTargets}
targetsCount={targetsCount}
label="Select Targets"
/>
</div>
);
}
}
export default QueryPageSelectTargets;

View File

@ -0,0 +1,173 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import { noop } from 'lodash';
import { campaignStub } from 'test/stubs';
import QueryPageSelectTargets from 'components/queries/QueryPageSelectTargets';
describe('QueryPageSelectTargets - component', () => {
const DEFAULT_CAMPAIGN = {
hosts_count: {
total: 0,
},
};
const defaultProps = {
campaign: DEFAULT_CAMPAIGN,
onFetchTargets: noop,
onRunQuery: noop,
onStopQuery: noop,
onTargetSelect: noop,
query: 'select * from users',
queryIsRunning: false,
selectedTargets: [],
targetsCount: 0,
};
afterEach(restoreSpies);
describe('rendering', () => {
const DefaultComponent = mount(<QueryPageSelectTargets {...defaultProps} />);
it('renders', () => {
expect(DefaultComponent.length).toEqual(1, 'QueryPageSelectTargets did not render');
});
it('renders a SelectTargetsDropdown component', () => {
const SelectTargetsDropdown = DefaultComponent.find('SelectTargetsDropdown');
expect(SelectTargetsDropdown.length).toEqual(1, 'SelectTargetsDropdown did not render');
});
it('renders a Run Query Button', () => {
const RunQueryButton = DefaultComponent.find('.query-page-select-targets__run-query-btn');
expect(RunQueryButton.length).toEqual(1, 'RunQueryButton did not render');
});
it('does not render a Stop Query Button', () => {
const StopQueryButton = DefaultComponent.find('.query-page-select-targets__stop-query-btn');
expect(StopQueryButton.length).toEqual(0, 'StopQueryButton is not expected to render');
});
it('does not render a Timer component', () => {
const Timer = DefaultComponent.find('Timer');
expect(Timer.length).toEqual(0, 'Timer is not expected to render');
});
it('does not render a ProgressBar component', () => {
const ProgressBar = DefaultComponent.find('ProgressBar');
expect(ProgressBar.length).toEqual(0, 'ProgressBar is not expected to render');
});
describe('when the campaign has results', () => {
describe('and the query is running', () => {
const props = {
...defaultProps,
campaign: campaignStub,
queryIsRunning: true,
};
const Component = mount(<QueryPageSelectTargets {...props} />);
it('renders a Timer component', () => {
const Timer = Component.find('Timer');
expect(Timer.length).toEqual(1, 'Timer is expected to render');
});
it('renders a Stop Query Button', () => {
const StopQueryButton = Component.find('.query-page-select-targets__stop-query-btn');
expect(StopQueryButton.length).toEqual(1, 'StopQueryButton is expected to render');
});
it('does not render a Run Query Button', () => {
const RunQueryButton = Component.find('.query-page-select-targets__run-query-btn');
expect(RunQueryButton.length).toEqual(0, 'RunQueryButton is not expected render');
});
it('renders a ProgressBar component', () => {
const ProgressBar = Component.find('ProgressBar');
expect(ProgressBar.length).toEqual(1, 'ProgressBar is expected to render');
});
});
describe('and the query is not running', () => {
const props = {
...defaultProps,
campaign: campaignStub,
queryIsRunning: false,
};
const Component = mount(<QueryPageSelectTargets {...props} />);
it('does not render a Timer component', () => {
const Timer = Component.find('Timer');
expect(Timer.length).toEqual(0, 'Timer is not expected to render');
});
it('does not render a Stop Query Button', () => {
const StopQueryButton = Component.find('.query-page-select-targets__stop-query-btn');
expect(StopQueryButton.length).toEqual(0, 'StopQueryButton is not expected to render');
});
it('renders a Run Query Button', () => {
const RunQueryButton = Component.find('.query-page-select-targets__run-query-btn');
expect(RunQueryButton.length).toEqual(1, 'RunQueryButton did not render');
});
it('renders a ProgressBar component', () => {
const ProgressBar = Component.find('ProgressBar');
expect(ProgressBar.length).toEqual(1, 'ProgressBar is expected to render');
});
});
});
});
describe('running a query', () => {
it('calls the onRunQuery prop with the query text', () => {
const spy = createSpy();
const query = 'select * from groups';
const props = {
...defaultProps,
campaign: campaignStub,
onRunQuery: spy,
query,
};
const Component = mount(<QueryPageSelectTargets {...props} />);
const RunQueryButton = Component.find('.query-page-select-targets__run-query-btn');
RunQueryButton.simulate('click');
expect(spy).toHaveBeenCalledWith(query);
});
});
describe('stopping a query', () => {
it('calls the onStopQuery prop', () => {
const spy = createSpy();
const props = {
...defaultProps,
campaign: campaignStub,
onStopQuery: spy,
queryIsRunning: true,
};
const Component = mount(<QueryPageSelectTargets {...props} />);
const StopQueryButton = Component.find('.query-page-select-targets__stop-query-btn');
StopQueryButton.simulate('click');
expect(spy).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,18 @@
.query-page-select-targets {
&__wrapper {
padding: $base;
}
&__progress-details {
display: inline-block;
width: 378px;
}
&__query-btn-wrapper {
float: right;
button {
margin-left: 10px;
}
}
}

View File

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

View File

@ -1,13 +1,12 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { get, keys, omit } from 'lodash'; import { keys, omit } from 'lodash';
import Button from 'components/buttons/Button'; import Button from 'components/buttons/Button';
import campaignInterface from 'interfaces/campaign'; import campaignInterface from 'interfaces/campaign';
import filterArrayByHash from 'utilities/filter_array_by_hash'; import filterArrayByHash from 'utilities/filter_array_by_hash';
import Icon from 'components/icons/Icon'; import Icon from 'components/icons/Icon';
import InputField from 'components/forms/fields/InputField'; import InputField from 'components/forms/fields/InputField';
import ProgressBar from 'components/loaders/ProgressBar';
import QueryResultsRow from 'components/queries/QueryResultsTable/QueryResultsRow'; import QueryResultsRow from 'components/queries/QueryResultsTable/QueryResultsRow';
const baseClass = 'query-results-table'; const baseClass = 'query-results-table';
@ -45,29 +44,6 @@ class QueryResultsTable extends Component {
}; };
} }
renderProgressDetails = () => {
const { campaign } = this.props;
const { hosts_count: hostsCount } = campaign;
const totalHostsCount = get(campaign, 'totals.count', 0);
const totalRowsCount = get(campaign, 'query_results.length', 0);
return (
<div className={`${baseClass}__progress-details`}>
<span>
<b>{hostsCount.total}</b>&nbsp;of&nbsp;
<b>{totalHostsCount} Hosts</b>&nbsp;Returning&nbsp;
<b>{totalRowsCount} Records&nbsp;</b>
({hostsCount.failed} failed)
</span>
<ProgressBar
error={hostsCount.failed}
max={totalHostsCount}
success={hostsCount.successful}
/>
</div>
);
}
renderTableHeaderRowData = (column, index) => { renderTableHeaderRowData = (column, index) => {
const filterable = column === 'hostname' ? 'host_hostname' : column; const filterable = column === 'hostname' ? 'host_hostname' : column;
const { activeColumn, resultsFilter } = this.state; const { activeColumn, resultsFilter } = this.state;
@ -127,7 +103,6 @@ class QueryResultsTable extends Component {
render () { render () {
const { campaign, onExportQueryResults } = this.props; const { campaign, onExportQueryResults } = this.props;
const { const {
renderProgressDetails,
renderTableHeaderRow, renderTableHeaderRow,
renderTableRows, renderTableRows,
} = this; } = this;
@ -139,8 +114,8 @@ class QueryResultsTable extends Component {
if (!hostsCount.successful) { if (!hostsCount.successful) {
return ( return (
<div className={baseClass}> <div className={`${baseClass} ${baseClass}__no-results`}>
{renderProgressDetails()} <em>No results found</em>
</div> </div>
); );
} }
@ -150,11 +125,10 @@ class QueryResultsTable extends Component {
<Button <Button
className={`${baseClass}__export-btn`} className={`${baseClass}__export-btn`}
onClick={onExportQueryResults} onClick={onExportQueryResults}
variant="brand" variant="link"
> >
Export Export
</Button> </Button>
{renderProgressDetails()}
<div className={`${baseClass}__table-wrapper`}> <div className={`${baseClass}__table-wrapper`}>
<table className={`${baseClass}__table`}> <table className={`${baseClass}__table`}>
<thead> <thead>

View File

@ -78,12 +78,6 @@ describe('QueryResultsTable - component', () => {
expect(componentWithoutQueryResults.html()).toNotExist(); 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', () => { it('sets the column headers to the keys of the query results', () => {
const queryResultKeys = keys(queryResult.rows[0]); const queryResultKeys = keys(queryResult.rows[0]);
const tableHeaderText = componentWithQueryResults.find('thead').text(); const tableHeaderText = componentWithQueryResults.find('thead').text();

View File

@ -1,7 +1,7 @@
.query-results-table { .query-results-table {
background-color: $white; background-color: $white;
padding: $pad-base; padding: $pad-base;
max-width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
&__export-btn { &__export-btn {
@ -14,6 +14,12 @@
} }
} }
&__no-results {
@include display(flex);
@include align-items(center);
@include justify-content(center);
}
&__progress-details { &__progress-details {
display: inline-block; display: inline-block;
width: 378px; width: 378px;
@ -24,8 +30,9 @@
border-radius: 3px; border-radius: 3px;
box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12); box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12);
overflow: scroll; overflow: scroll;
margin-top: 20px; margin-top: 58px;
max-height: 550px; max-height: 550px;
width: 100%;
} }
&__table { &__table {

View File

@ -18,6 +18,7 @@ import QueryForm from 'components/forms/queries/QueryForm';
import osqueryTableInterface from 'interfaces/osquery_table'; import osqueryTableInterface from 'interfaces/osquery_table';
import queryActions from 'redux/nodes/entities/queries/actions'; import queryActions from 'redux/nodes/entities/queries/actions';
import queryInterface from 'interfaces/query'; import queryInterface from 'interfaces/query';
import QueryPageSelectTargets from 'components/queries/QueryPageSelectTargets';
import QueryResultsTable from 'components/queries/QueryResultsTable'; import QueryResultsTable from 'components/queries/QueryResultsTable';
import QuerySidePanel from 'components/side_panels/QuerySidePanel'; import QuerySidePanel from 'components/side_panels/QuerySidePanel';
import { renderFlash } from 'redux/nodes/notifications/actions'; import { renderFlash } from 'redux/nodes/notifications/actions';
@ -27,6 +28,11 @@ import validateQuery from 'components/forms/validators/validate_query';
import Spinner from 'components/loaders/Spinner'; import Spinner from 'components/loaders/Spinner';
const baseClass = 'query-page'; const baseClass = 'query-page';
const DEFAULT_CAMPAIGN = {
hosts_count: {
total: 0,
},
};
export class QueryPage extends Component { export class QueryPage extends Component {
static propTypes = { static propTypes = {
@ -52,10 +58,9 @@ export class QueryPage extends Component {
super(props); super(props);
this.state = { this.state = {
campaign: { campaign: DEFAULT_CAMPAIGN,
hosts_count: { total: 0 },
},
queryIsRunning: false, queryIsRunning: false,
queryText: props.query.query,
targetsCount: 0, targetsCount: 0,
targetsError: null, targetsError: null,
}; };
@ -95,6 +100,10 @@ export class QueryPage extends Component {
this.csvQueryName = value; this.csvQueryName = value;
} }
if (fieldName === 'query') {
this.setState({ queryText: value });
}
return false; return false;
} }
@ -271,7 +280,7 @@ export class QueryPage extends Component {
if (this.campaign || campaign) { if (this.campaign || campaign) {
this.campaign = null; this.campaign = null;
this.setState({ campaign: {} }); this.setState({ campaign: DEFAULT_CAMPAIGN });
} }
return false; return false;
@ -307,6 +316,10 @@ export class QueryPage extends Component {
}); });
let resultBody = ''; let resultBody = '';
if (!loading && isEqual(campaign, DEFAULT_CAMPAIGN)) {
return false;
}
if (loading) { if (loading) {
resultBody = <Spinner />; resultBody = <Spinner />;
} else { } else {
@ -320,26 +333,45 @@ export class QueryPage extends Component {
); );
} }
renderTargetsInput = () => {
const { onFetchTargets, onRunQuery, onStopQuery, onTargetSelect } = this;
const { campaign, queryIsRunning, queryText, targetsCount, targetsError } = this.state;
const { selectedTargets } = this.props;
return (
<QueryPageSelectTargets
campaign={campaign}
error={targetsError}
onFetchTargets={onFetchTargets}
onRunQuery={onRunQuery}
onStopQuery={onStopQuery}
onTargetSelect={onTargetSelect}
query={queryText}
queryIsRunning={queryIsRunning}
selectedTargets={selectedTargets}
targetsCount={targetsCount}
/>
);
}
render () { render () {
const { const {
onChangeQueryFormField, onChangeQueryFormField,
onFetchTargets,
onOsqueryTableSelect, onOsqueryTableSelect,
onRunQuery, onRunQuery,
onSaveQueryFormSubmit, onSaveQueryFormSubmit,
onStopQuery, onStopQuery,
onTargetSelect,
onTextEditorInputChange, onTextEditorInputChange,
onUpdateQuery, onUpdateQuery,
renderResultsTable, renderResultsTable,
renderTargetsInput,
} = this; } = this;
const { queryIsRunning, targetsCount, targetsError } = this.state; const { queryIsRunning } = this.state;
const { const {
errors, errors,
loadingQueries, loadingQueries,
query, query,
selectedOsqueryTable, selectedOsqueryTable,
selectedTargets,
} = this.props; } = this.props;
if (loadingQueries) { if (loadingQueries) {
@ -354,20 +386,16 @@ export class QueryPage extends Component {
formData={query} formData={query}
handleSubmit={onSaveQueryFormSubmit} handleSubmit={onSaveQueryFormSubmit}
onChangeFunc={onChangeQueryFormField} onChangeFunc={onChangeQueryFormField}
onFetchTargets={onFetchTargets}
onOsqueryTableSelect={onOsqueryTableSelect} onOsqueryTableSelect={onOsqueryTableSelect}
onRunQuery={onRunQuery} onRunQuery={onRunQuery}
onStopQuery={onStopQuery} onStopQuery={onStopQuery}
onTargetSelect={onTargetSelect}
onUpdate={onUpdateQuery} onUpdate={onUpdateQuery}
queryIsRunning={queryIsRunning} queryIsRunning={queryIsRunning}
selectedTargets={selectedTargets}
serverErrors={errors} serverErrors={errors}
targetsCount={targetsCount}
targetsError={targetsError}
selectedOsqueryTable={selectedOsqueryTable} selectedOsqueryTable={selectedOsqueryTable}
/> />
</div> </div>
{renderTargetsInput()}
{renderResultsTable()} {renderResultsTable()}
</div> </div>
<QuerySidePanel <QuerySidePanel

View File

@ -11,7 +11,7 @@ import helpers from 'test/helpers';
import hostActions from 'redux/nodes/entities/hosts/actions'; import hostActions from 'redux/nodes/entities/hosts/actions';
import queryActions from 'redux/nodes/entities/queries/actions'; import queryActions from 'redux/nodes/entities/queries/actions';
import ConnectedQueryPage, { QueryPage } from 'pages/queries/QueryPage/QueryPage'; import ConnectedQueryPage, { QueryPage } from 'pages/queries/QueryPage/QueryPage';
import { hostStub } from 'test/stubs'; import { hostStub, queryStub } from 'test/stubs';
const { connectedComponent, createAceSpy, fillInFormInput, reduxMockStore } = helpers; const { connectedComponent, createAceSpy, fillInFormInput, reduxMockStore } = helpers;
const { defaultSelectedOsqueryTable } = queryPageActions; const { defaultSelectedOsqueryTable } = queryPageActions;
@ -113,14 +113,14 @@ describe('QueryPage - component', () => {
it('sets targetError in state when the query is run and there are no selected targets', () => { it('sets targetError in state when the query is run and there are no selected targets', () => {
const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp })); const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp }));
const form = page.find('QueryForm'); const QueryPageSelectTargets = page.find('QueryPageSelectTargets');
const runQueryBtn = form.find('.query-form__run-query-btn'); const runQueryBtn = page.find('.query-page-select-targets__run-query-btn');
expect(form.prop('targetsError')).toNotExist(); expect(QueryPageSelectTargets.prop('error')).toNotExist();
runQueryBtn.simulate('click'); runQueryBtn.simulate('click');
expect(form.prop('targetsError')).toEqual('You must select at least one target to run a query'); expect(QueryPageSelectTargets.prop('error')).toEqual('You must select at least one target to run a query');
}); });
it('calls the onUpdateQuery prop when the query is updated', () => { it('calls the onUpdateQuery prop when the query is updated', () => {
@ -165,11 +165,12 @@ describe('QueryPage - component', () => {
describe('#componentWillReceiveProps', () => { describe('#componentWillReceiveProps', () => {
it('resets selected targets and removed the campaign when the hostname changes', () => { it('resets selected targets and removed the campaign when the hostname changes', () => {
const queryResult = { org_name: 'Kolide', org_url: 'https://kolide.co' }; const queryResult = { org_name: 'Kolide', org_url: 'https://kolide.co' };
const campaign = { id: 1, query_results: [queryResult] }; const campaign = { id: 1, query_results: [queryResult], hosts_count: { total: 1 } };
const props = { const props = {
dispatch: noop, dispatch: noop,
loadingQueries: false, loadingQueries: false,
location: { pathname: '/queries/11' }, location: { pathname: '/queries/11' },
query: { query: 'select * from users' },
selectedOsqueryTable: defaultSelectedOsqueryTable, selectedOsqueryTable: defaultSelectedOsqueryTable,
selectedTargets: [hostStub], selectedTargets: [hostStub],
}; };
@ -203,7 +204,7 @@ describe('QueryPage - component', () => {
}; };
const queryResultsCSV = convertToCSV([queryResult]); const queryResultsCSV = convertToCSV([queryResult]);
const fileSaveSpy = spyOn(FileSave, 'saveAs'); const fileSaveSpy = spyOn(FileSave, 'saveAs');
const Page = mount(<QueryPage dispatch={noop} selectedOsqueryTable={defaultSelectedOsqueryTable} />); const Page = mount(<QueryPage dispatch={noop} query={queryStub} selectedOsqueryTable={defaultSelectedOsqueryTable} />);
const filename = 'query_results.csv'; const filename = 'query_results.csv';
const fileStub = new global.window.File([queryResultsCSV], filename, { type: 'text/csv' }); const fileStub = new global.window.File([queryResultsCSV], filename, { type: 'text/csv' });

View File

@ -9,6 +9,7 @@
&__results { &__results {
@include display(flex); @include display(flex);
@include flex-grow(1); @include flex-grow(1);
min-height: 400px;
&--loading { &--loading {
@include align-items(center); @include align-items(center);

View File

@ -204,8 +204,36 @@ export const userStub = {
username: 'gnardog', username: 'gnardog',
}; };
const queryResultStub = {
description: 'root',
directory: '/root',
gid: '0',
gid_signed: '0',
groupname: 'root',
host_hostname: hostStub.hostname,
};
export const campaignStub = {
hosts: [hostStub, { ...hostStub, id: 100 }],
hosts_count: {
failed: 0,
successful: 2,
total: 2,
},
id: 1,
query_id: queryStub.id,
query_results: [queryResultStub],
totals: {
count: 2,
missing_in_action: 0,
offline: 0,
online: 2,
},
};
export default { export default {
adminUserStub, adminUserStub,
campaignStub,
configStub, configStub,
flatConfigStub, flatConfigStub,
hostStub, hostStub,