зеркало из https://github.com/mozilla/fleet.git
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:
Родитель
146ee18c62
Коммит
803bc41366
|
@ -14,6 +14,7 @@ class KolideAce extends Component {
|
|||
static propTypes = {
|
||||
error: PropTypes.string,
|
||||
fontSize: PropTypes.number,
|
||||
label: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
onLoad: PropTypes.func,
|
||||
|
@ -31,6 +32,18 @@ class KolideAce extends Component {
|
|||
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 () {
|
||||
const {
|
||||
error,
|
||||
|
@ -44,6 +57,7 @@ class KolideAce extends Component {
|
|||
wrapEnabled,
|
||||
wrapperClassName,
|
||||
} = this.props;
|
||||
const { renderLabel } = this;
|
||||
|
||||
const wrapperClass = classnames(wrapperClassName, {
|
||||
[`${baseClass}__wrapper--error`]: error,
|
||||
|
@ -51,7 +65,7 @@ class KolideAce extends Component {
|
|||
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<div className={`${baseClass}__error-field`}>{error}</div>
|
||||
{renderLabel()}
|
||||
<AceEditor
|
||||
enableBasicAutocompletion
|
||||
enableLiveAutocompletion
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
.kolide-ace {
|
||||
&__error-field {
|
||||
color: $alert;
|
||||
display: block;
|
||||
&__label {
|
||||
font-size: 16px;
|
||||
font-stretch: normal;
|
||||
font-style: normal;
|
||||
font-weight: $bold;
|
||||
font-style: normal;
|
||||
font-stretch: normal;
|
||||
letter-spacing: -0.5px;
|
||||
color: $text-dark;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
min-height: 25px;
|
||||
|
||||
&--error {
|
||||
color: $alert;
|
||||
}
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
|
|
|
@ -75,6 +75,10 @@ $base-class: 'button';
|
|||
@include button-variant($warning);
|
||||
}
|
||||
|
||||
&--link {
|
||||
@include button-variant($link);
|
||||
}
|
||||
|
||||
&--inverse {
|
||||
@include button-variant($white, $inverse-hover, $brand);
|
||||
color: $brand;
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { size } from 'lodash';
|
||||
|
||||
import Button from 'components/buttons/Button';
|
||||
import DropdownButton from 'components/buttons/DropdownButton';
|
||||
import Form from 'components/forms/Form';
|
||||
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 KolideAce from 'components/KolideAce';
|
||||
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 Timer from 'components/loaders/Timer';
|
||||
|
||||
const baseClass = 'query-form';
|
||||
|
||||
|
@ -46,16 +42,9 @@ class QueryForm extends Component {
|
|||
}).isRequired,
|
||||
handleSubmit: PropTypes.func,
|
||||
formData: queryInterface,
|
||||
onFetchTargets: PropTypes.func,
|
||||
onOsqueryTableSelect: PropTypes.func,
|
||||
onRunQuery: PropTypes.func,
|
||||
onStopQuery: PropTypes.func,
|
||||
onTargetSelect: PropTypes.func,
|
||||
onUpdate: PropTypes.func,
|
||||
queryIsRunning: PropTypes.bool,
|
||||
selectedTargets: PropTypes.arrayOf(targetInterface),
|
||||
targetsCount: PropTypes.number,
|
||||
targetsError: PropTypes.string,
|
||||
};
|
||||
|
||||
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) => {
|
||||
evt.preventDefault();
|
||||
|
||||
|
@ -126,113 +105,58 @@ class QueryForm extends Component {
|
|||
|
||||
renderButtons = () => {
|
||||
const { canSaveAsNew, canSaveChanges } = helpers;
|
||||
const {
|
||||
fields,
|
||||
formData,
|
||||
handleSubmit,
|
||||
onStopQuery,
|
||||
queryIsRunning,
|
||||
} = this.props;
|
||||
const { onRunQuery, onUpdate } = this;
|
||||
const { fields, formData, handleSubmit } = this.props;
|
||||
const { onUpdate } = this;
|
||||
|
||||
const dropdownBtnOptions = [{
|
||||
disabled: !canSaveChanges(fields, formData),
|
||||
label: 'Save Changes',
|
||||
onClick: onUpdate,
|
||||
}, {
|
||||
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>
|
||||
);
|
||||
}
|
||||
const dropdownBtnOptions = [
|
||||
{
|
||||
disabled: !canSaveChanges(fields, formData),
|
||||
label: 'Save Changes',
|
||||
onClick: onUpdate,
|
||||
},
|
||||
{
|
||||
disabled: !canSaveAsNew(fields, formData),
|
||||
label: 'Save As New...',
|
||||
onClick: handleSubmit,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__button-wrap`}>
|
||||
{queryIsRunning && <Timer running={queryIsRunning} />}
|
||||
|
||||
<DropdownButton
|
||||
className={`${baseClass}__save`}
|
||||
options={dropdownBtnOptions}
|
||||
variant="success"
|
||||
variant="brand"
|
||||
>
|
||||
Save
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { errors } = this.state;
|
||||
const { baseError, fields, handleSubmit, queryIsRunning } = this.props;
|
||||
const { onLoad, renderButtons, renderTargetsInput } = this;
|
||||
const { errors } = this.state;
|
||||
const { onLoad, renderButtons } = this;
|
||||
|
||||
return (
|
||||
<form className={`${baseClass}__wrapper`} onSubmit={handleSubmit}>
|
||||
<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>}
|
||||
{renderTargetsInput()}
|
||||
<InputField
|
||||
{...fields.name}
|
||||
error={fields.name.error || errors.name}
|
||||
inputClassName={`${baseClass}__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
|
||||
{...fields.description}
|
||||
inputClassName={`${baseClass}__query-description`}
|
||||
|
|
|
@ -37,36 +37,6 @@ 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 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', () => {
|
||||
const updateSpy = createSpy();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
.query-form {
|
||||
&__wrapper {
|
||||
padding: $base;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
&__query-title,
|
||||
|
|
|
@ -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> of
|
||||
<b>{totalHostsCount} Hosts</b> Returning
|
||||
<b>{totalRowsCount} Records </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;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export default from './QueryPageSelectTargets';
|
|
@ -1,13 +1,12 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { get, keys, omit } from 'lodash';
|
||||
import { 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';
|
||||
import InputField from 'components/forms/fields/InputField';
|
||||
import ProgressBar from 'components/loaders/ProgressBar';
|
||||
import QueryResultsRow from 'components/queries/QueryResultsTable/QueryResultsRow';
|
||||
|
||||
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> of
|
||||
<b>{totalHostsCount} Hosts</b> Returning
|
||||
<b>{totalRowsCount} Records </b>
|
||||
({hostsCount.failed} failed)
|
||||
</span>
|
||||
<ProgressBar
|
||||
error={hostsCount.failed}
|
||||
max={totalHostsCount}
|
||||
success={hostsCount.successful}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderTableHeaderRowData = (column, index) => {
|
||||
const filterable = column === 'hostname' ? 'host_hostname' : column;
|
||||
const { activeColumn, resultsFilter } = this.state;
|
||||
|
@ -127,7 +103,6 @@ class QueryResultsTable extends Component {
|
|||
render () {
|
||||
const { campaign, onExportQueryResults } = this.props;
|
||||
const {
|
||||
renderProgressDetails,
|
||||
renderTableHeaderRow,
|
||||
renderTableRows,
|
||||
} = this;
|
||||
|
@ -139,8 +114,8 @@ class QueryResultsTable extends Component {
|
|||
|
||||
if (!hostsCount.successful) {
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{renderProgressDetails()}
|
||||
<div className={`${baseClass} ${baseClass}__no-results`}>
|
||||
<em>No results found</em>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -150,11 +125,10 @@ class QueryResultsTable extends Component {
|
|||
<Button
|
||||
className={`${baseClass}__export-btn`}
|
||||
onClick={onExportQueryResults}
|
||||
variant="brand"
|
||||
variant="link"
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
{renderProgressDetails()}
|
||||
<div className={`${baseClass}__table-wrapper`}>
|
||||
<table className={`${baseClass}__table`}>
|
||||
<thead>
|
||||
|
|
|
@ -78,12 +78,6 @@ describe('QueryResultsTable - component', () => {
|
|||
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();
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
.query-results-table {
|
||||
background-color: $white;
|
||||
padding: $pad-base;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__export-btn {
|
||||
|
@ -14,6 +14,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
@include display(flex);
|
||||
@include align-items(center);
|
||||
@include justify-content(center);
|
||||
}
|
||||
|
||||
&__progress-details {
|
||||
display: inline-block;
|
||||
width: 378px;
|
||||
|
@ -24,8 +30,9 @@
|
|||
border-radius: 3px;
|
||||
box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12);
|
||||
overflow: scroll;
|
||||
margin-top: 20px;
|
||||
margin-top: 58px;
|
||||
max-height: 550px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__table {
|
||||
|
|
|
@ -18,6 +18,7 @@ import QueryForm from 'components/forms/queries/QueryForm';
|
|||
import osqueryTableInterface from 'interfaces/osquery_table';
|
||||
import queryActions from 'redux/nodes/entities/queries/actions';
|
||||
import queryInterface from 'interfaces/query';
|
||||
import QueryPageSelectTargets from 'components/queries/QueryPageSelectTargets';
|
||||
import QueryResultsTable from 'components/queries/QueryResultsTable';
|
||||
import QuerySidePanel from 'components/side_panels/QuerySidePanel';
|
||||
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';
|
||||
|
||||
const baseClass = 'query-page';
|
||||
const DEFAULT_CAMPAIGN = {
|
||||
hosts_count: {
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export class QueryPage extends Component {
|
||||
static propTypes = {
|
||||
|
@ -52,10 +58,9 @@ export class QueryPage extends Component {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
campaign: {
|
||||
hosts_count: { total: 0 },
|
||||
},
|
||||
campaign: DEFAULT_CAMPAIGN,
|
||||
queryIsRunning: false,
|
||||
queryText: props.query.query,
|
||||
targetsCount: 0,
|
||||
targetsError: null,
|
||||
};
|
||||
|
@ -95,6 +100,10 @@ export class QueryPage extends Component {
|
|||
this.csvQueryName = value;
|
||||
}
|
||||
|
||||
if (fieldName === 'query') {
|
||||
this.setState({ queryText: value });
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -271,7 +280,7 @@ export class QueryPage extends Component {
|
|||
|
||||
if (this.campaign || campaign) {
|
||||
this.campaign = null;
|
||||
this.setState({ campaign: {} });
|
||||
this.setState({ campaign: DEFAULT_CAMPAIGN });
|
||||
}
|
||||
|
||||
return false;
|
||||
|
@ -307,6 +316,10 @@ export class QueryPage extends Component {
|
|||
});
|
||||
let resultBody = '';
|
||||
|
||||
if (!loading && isEqual(campaign, DEFAULT_CAMPAIGN)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
resultBody = <Spinner />;
|
||||
} 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 () {
|
||||
const {
|
||||
onChangeQueryFormField,
|
||||
onFetchTargets,
|
||||
onOsqueryTableSelect,
|
||||
onRunQuery,
|
||||
onSaveQueryFormSubmit,
|
||||
onStopQuery,
|
||||
onTargetSelect,
|
||||
onTextEditorInputChange,
|
||||
onUpdateQuery,
|
||||
renderResultsTable,
|
||||
renderTargetsInput,
|
||||
} = this;
|
||||
const { queryIsRunning, targetsCount, targetsError } = this.state;
|
||||
const { queryIsRunning } = this.state;
|
||||
const {
|
||||
errors,
|
||||
loadingQueries,
|
||||
query,
|
||||
selectedOsqueryTable,
|
||||
selectedTargets,
|
||||
} = this.props;
|
||||
|
||||
if (loadingQueries) {
|
||||
|
@ -354,20 +386,16 @@ export class QueryPage extends Component {
|
|||
formData={query}
|
||||
handleSubmit={onSaveQueryFormSubmit}
|
||||
onChangeFunc={onChangeQueryFormField}
|
||||
onFetchTargets={onFetchTargets}
|
||||
onOsqueryTableSelect={onOsqueryTableSelect}
|
||||
onRunQuery={onRunQuery}
|
||||
onStopQuery={onStopQuery}
|
||||
onTargetSelect={onTargetSelect}
|
||||
onUpdate={onUpdateQuery}
|
||||
queryIsRunning={queryIsRunning}
|
||||
selectedTargets={selectedTargets}
|
||||
serverErrors={errors}
|
||||
targetsCount={targetsCount}
|
||||
targetsError={targetsError}
|
||||
selectedOsqueryTable={selectedOsqueryTable}
|
||||
/>
|
||||
</div>
|
||||
{renderTargetsInput()}
|
||||
{renderResultsTable()}
|
||||
</div>
|
||||
<QuerySidePanel
|
||||
|
|
|
@ -11,7 +11,7 @@ import helpers from 'test/helpers';
|
|||
import hostActions from 'redux/nodes/entities/hosts/actions';
|
||||
import queryActions from 'redux/nodes/entities/queries/actions';
|
||||
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 { 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', () => {
|
||||
const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp }));
|
||||
const form = page.find('QueryForm');
|
||||
const runQueryBtn = form.find('.query-form__run-query-btn');
|
||||
const QueryPageSelectTargets = page.find('QueryPageSelectTargets');
|
||||
const runQueryBtn = page.find('.query-page-select-targets__run-query-btn');
|
||||
|
||||
expect(form.prop('targetsError')).toNotExist();
|
||||
expect(QueryPageSelectTargets.prop('error')).toNotExist();
|
||||
|
||||
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', () => {
|
||||
|
@ -165,11 +165,12 @@ describe('QueryPage - component', () => {
|
|||
describe('#componentWillReceiveProps', () => {
|
||||
it('resets selected targets and removed the campaign when the hostname changes', () => {
|
||||
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 = {
|
||||
dispatch: noop,
|
||||
loadingQueries: false,
|
||||
location: { pathname: '/queries/11' },
|
||||
query: { query: 'select * from users' },
|
||||
selectedOsqueryTable: defaultSelectedOsqueryTable,
|
||||
selectedTargets: [hostStub],
|
||||
};
|
||||
|
@ -203,7 +204,7 @@ describe('QueryPage - component', () => {
|
|||
};
|
||||
const queryResultsCSV = convertToCSV([queryResult]);
|
||||
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 fileStub = new global.window.File([queryResultsCSV], filename, { type: 'text/csv' });
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
&__results {
|
||||
@include display(flex);
|
||||
@include flex-grow(1);
|
||||
min-height: 400px;
|
||||
|
||||
&--loading {
|
||||
@include align-items(center);
|
||||
|
|
|
@ -204,8 +204,36 @@ export const userStub = {
|
|||
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 {
|
||||
adminUserStub,
|
||||
campaignStub,
|
||||
configStub,
|
||||
flatConfigStub,
|
||||
hostStub,
|
||||
|
|
Загрузка…
Ссылка в новой задаче