зеркало из https://github.com/mozilla/fleet.git
Full Screen Query Results (#1238)
This commit is contained in:
Родитель
6c6fb33915
Коммит
b7fb83ce4b
Двоичные данные
assets/fonts/kolidecons/kolidecons.woff
Двоичные данные
assets/fonts/kolidecons/kolidecons.woff
Двоичный файл не отображается.
Двоичные данные
assets/fonts/kolidecons/kolidecons.woff2
Двоичные данные
assets/fonts/kolidecons/kolidecons.woff2
Двоичный файл не отображается.
|
@ -6,62 +6,11 @@ const baseClass = 'kolide-timer';
|
|||
|
||||
class Timer extends Component {
|
||||
static propTypes = {
|
||||
running: PropTypes.bool,
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = { totalMilliseconds: 0 };
|
||||
}
|
||||
|
||||
componentWillReceiveProps ({ running }) {
|
||||
const { running: currentRunning } = this.props;
|
||||
|
||||
if (running) {
|
||||
if (!currentRunning) {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
this.play();
|
||||
} else {
|
||||
this.pause();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.pause();
|
||||
}
|
||||
|
||||
play = () => {
|
||||
const { interval, update } = this;
|
||||
|
||||
if (!interval) {
|
||||
this.interval = setInterval(update, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
pause = () => {
|
||||
const { interval } = this;
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.setState({ totalMilliseconds: 0 });
|
||||
}
|
||||
|
||||
update = () => {
|
||||
const { totalMilliseconds } = this.state;
|
||||
|
||||
this.setState({ totalMilliseconds: totalMilliseconds + 1000 });
|
||||
totalMilliseconds: PropTypes.number,
|
||||
}
|
||||
|
||||
render () {
|
||||
const { totalMilliseconds } = this.state;
|
||||
const { totalMilliseconds } = this.props;
|
||||
|
||||
return (
|
||||
<span className={baseClass}>{convertSeconds(totalMilliseconds)}</span>
|
||||
|
|
|
@ -1,46 +1,24 @@
|
|||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import expect, { spyOn, restoreSpies } from 'expect';
|
||||
import expect from 'expect';
|
||||
|
||||
import Timer from './Timer';
|
||||
|
||||
describe('Timer - component', () => {
|
||||
afterEach(restoreSpies);
|
||||
it('renders with proper time', () => {
|
||||
const timer1 = mount(<Timer totalMilliseconds={1000} />);
|
||||
const elem1 = timer1.find('.kolide-timer');
|
||||
|
||||
it('play() and pause() function', () => {
|
||||
const timer = mount(<Timer running={false} />);
|
||||
expect(elem1.text()).toEqual('00:00:01');
|
||||
|
||||
expect(timer.node.interval).toNotExist();
|
||||
timer.setProps({ running: true });
|
||||
expect(timer.node.interval).toExist();
|
||||
timer.setProps({ running: false });
|
||||
expect(timer.node.interval).toNotExist();
|
||||
});
|
||||
const timer2 = mount(<Timer totalMilliseconds={60000} />);
|
||||
const elem2 = timer2.find('.kolide-timer');
|
||||
|
||||
it('should reset after pause', () => {
|
||||
const timer = mount(<Timer running={false} />);
|
||||
const spy = spyOn(timer.node, 'reset').andCallThrough();
|
||||
expect(elem2.text()).toEqual('00:01:00');
|
||||
|
||||
timer.setProps({ running: true });
|
||||
const timer3 = mount(<Timer totalMilliseconds={3600000} />);
|
||||
const elem3 = timer3.find('.kolide-timer');
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not reset when stopped', () => {
|
||||
const timer = mount(<Timer running />);
|
||||
const spy = spyOn(timer.node, 'reset').andCallThrough();
|
||||
|
||||
timer.setProps({ running: false });
|
||||
|
||||
expect(spy).toNotHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not reset when it continues', () => {
|
||||
const timer = mount(<Timer running />);
|
||||
const spy = spyOn(timer.node, 'reset').andCallThrough();
|
||||
|
||||
timer.setProps({ running: true });
|
||||
|
||||
expect(spy).toNotHaveBeenCalled();
|
||||
expect(elem3.text()).toEqual('01:00:00');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
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 QueryProgressDetails from 'components/queries/QueryProgressDetails';
|
||||
import SelectTargetsDropdown from 'components/forms/fields/SelectTargetsDropdown';
|
||||
import targetInterface from 'interfaces/target';
|
||||
import Timer from 'components/loaders/Timer';
|
||||
|
||||
const baseClass = 'query-page-select-targets';
|
||||
|
||||
|
@ -22,79 +19,9 @@ class QueryPageSelectTargets extends Component {
|
|||
queryIsRunning: PropTypes.bool,
|
||||
selectedTargets: PropTypes.arrayOf(targetInterface),
|
||||
targetsCount: PropTypes.number,
|
||||
queryTimerMilliseconds: 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,
|
||||
|
@ -102,12 +29,24 @@ class QueryPageSelectTargets extends Component {
|
|||
onTargetSelect,
|
||||
selectedTargets,
|
||||
targetsCount,
|
||||
campaign,
|
||||
onRunQuery,
|
||||
onStopQuery,
|
||||
query,
|
||||
queryIsRunning,
|
||||
queryTimerMilliseconds,
|
||||
} = this.props;
|
||||
const { renderProgressDetails } = this;
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__wrapper body-wrap`}>
|
||||
{renderProgressDetails()}
|
||||
<QueryProgressDetails
|
||||
campaign={campaign}
|
||||
onRunQuery={onRunQuery}
|
||||
onStopQuery={onStopQuery}
|
||||
query={query}
|
||||
queryIsRunning={queryIsRunning}
|
||||
queryTimerMilliseconds={queryTimerMilliseconds}
|
||||
/>
|
||||
<SelectTargetsDropdown
|
||||
error={error}
|
||||
onFetchTargets={onFetchTargets}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import React from 'react';
|
||||
import expect, { createSpy, restoreSpies } from 'expect';
|
||||
import expect from 'expect';
|
||||
import { mount } from 'enzyme';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { campaignStub } from 'test/stubs';
|
||||
import QueryPageSelectTargets from 'components/queries/QueryPageSelectTargets';
|
||||
|
||||
describe('QueryPageSelectTargets - component', () => {
|
||||
|
@ -25,8 +24,6 @@ describe('QueryPageSelectTargets - component', () => {
|
|||
targetsCount: 0,
|
||||
};
|
||||
|
||||
afterEach(restoreSpies);
|
||||
|
||||
describe('rendering', () => {
|
||||
const DefaultComponent = mount(<QueryPageSelectTargets {...defaultProps} />);
|
||||
|
||||
|
@ -40,134 +37,10 @@ describe('QueryPageSelectTargets - component', () => {
|
|||
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');
|
||||
it('renders a QueryProgressDetails component', () => {
|
||||
const QueryProgressDetails = DefaultComponent.find('QueryProgressDetails');
|
||||
|
||||
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();
|
||||
expect(QueryProgressDetails.length).toEqual(1, 'QueryProgressDetails did not render');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,17 +2,4 @@
|
|||
&__wrapper {
|
||||
padding: $base;
|
||||
}
|
||||
|
||||
&__progress-details {
|
||||
display: inline-block;
|
||||
width: 378px;
|
||||
}
|
||||
|
||||
&__query-btn-wrapper {
|
||||
float: right;
|
||||
|
||||
button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
import React, { 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 Timer from 'components/loaders/Timer';
|
||||
|
||||
const baseClass = 'query-progress-details';
|
||||
|
||||
const QueryProgressDetails = ({ campaign, className, onRunQuery, onStopQuery, query, queryIsRunning, queryTimerMilliseconds }) => {
|
||||
const handleRunQuery = () => {
|
||||
return onRunQuery(query);
|
||||
};
|
||||
|
||||
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}__btn-wrapper`}>
|
||||
<Button
|
||||
className={`${baseClass}__run-btn`}
|
||||
onClick={handleRunQuery}
|
||||
variant="success"
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const stopQueryBtn = (
|
||||
<div className={`${baseClass}__btn-wrapper`}>
|
||||
<Button
|
||||
className={`${baseClass}__stop-btn`}
|
||||
onClick={onStopQuery}
|
||||
variant="alert"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!hostsCount.total) {
|
||||
return (
|
||||
<div className={`${baseClass} ${className}`}>
|
||||
<div className={`${baseClass}__wrapper`} />
|
||||
{queryIsRunning ? stopQueryBtn : runQueryBtn}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${baseClass} ${className}`}>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
<span>
|
||||
<b>{hostsCount.total}</b> of
|
||||
<b>{totalHostsCount} Hosts</b> Returning
|
||||
<b>{totalRowsCount} Records </b>
|
||||
<em>({hostsCount.failed} failed)</em>
|
||||
</span>
|
||||
<ProgressBar
|
||||
error={hostsCount.failed}
|
||||
max={totalHostsCount}
|
||||
success={hostsCount.successful}
|
||||
/>
|
||||
{queryIsRunning && <Timer totalMilliseconds={queryTimerMilliseconds} />}
|
||||
</div>
|
||||
{queryIsRunning ? stopQueryBtn : runQueryBtn}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
QueryProgressDetails.propTypes = {
|
||||
campaign: campaignInterface,
|
||||
className: PropTypes.string,
|
||||
onRunQuery: PropTypes.func.isRequired,
|
||||
onStopQuery: PropTypes.func.isRequired,
|
||||
query: PropTypes.string,
|
||||
queryIsRunning: PropTypes.bool,
|
||||
queryTimerMilliseconds: PropTypes.number,
|
||||
};
|
||||
|
||||
export default QueryProgressDetails;
|
|
@ -0,0 +1,163 @@
|
|||
import React from 'react';
|
||||
import expect, { createSpy, restoreSpies } from 'expect';
|
||||
import { mount } from 'enzyme';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { campaignStub } from 'test/stubs';
|
||||
import QueryProgressDetails from './QueryProgressDetails';
|
||||
|
||||
describe('QueryProgressDetails - component', () => {
|
||||
const DEFAULT_CAMPAIGN = {
|
||||
hosts_count: {
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
campaign: DEFAULT_CAMPAIGN,
|
||||
onRunQuery: noop,
|
||||
onStopQuery: noop,
|
||||
query: 'select * from users',
|
||||
queryIsRunning: false,
|
||||
};
|
||||
|
||||
afterEach(restoreSpies);
|
||||
|
||||
describe('rendering', () => {
|
||||
const DefaultComponent = mount(<QueryProgressDetails {...defaultProps} />);
|
||||
|
||||
it('renders', () => {
|
||||
expect(DefaultComponent.length).toEqual(1, 'QueryProgressDetails did not render');
|
||||
});
|
||||
|
||||
it('renders a Run Query Button', () => {
|
||||
const RunQueryButton = DefaultComponent.find('.query-progress-details__run-btn');
|
||||
|
||||
expect(RunQueryButton.length).toEqual(1, 'RunQueryButton did not render');
|
||||
});
|
||||
|
||||
it('does not render a Stop Query Button', () => {
|
||||
const StopQueryButton = DefaultComponent.find('.query-progress-details__stop-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(<QueryProgressDetails {...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-progress-details__stop-btn');
|
||||
|
||||
expect(StopQueryButton.length).toEqual(1, 'StopQueryButton is expected to render');
|
||||
});
|
||||
|
||||
it('does not render a Run Query Button', () => {
|
||||
const RunQueryButton = Component.find('.query-progress-details__run-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(<QueryProgressDetails {...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-progress-details__stop-btn');
|
||||
|
||||
expect(StopQueryButton.length).toEqual(0, 'StopQueryButton is not expected to render');
|
||||
});
|
||||
|
||||
it('renders a Run Query Button', () => {
|
||||
const RunQueryButton = Component.find('.query-progress-details__run-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(<QueryProgressDetails {...props} />);
|
||||
const RunQueryButton = Component.find('.query-progress-details__run-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(<QueryProgressDetails {...props} />);
|
||||
const StopQueryButton = Component.find('.query-progress-details__stop-btn');
|
||||
|
||||
StopQueryButton.simulate('click');
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,23 @@
|
|||
.query-progress-details {
|
||||
@at-root &.query-results-table__full-screen {
|
||||
float: left;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
display: inline-block;
|
||||
max-width: 420px;
|
||||
|
||||
em {
|
||||
color: $accent-text;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-wrapper {
|
||||
float: right;
|
||||
|
||||
.button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export default from './QueryProgressDetails';
|
|
@ -8,6 +8,8 @@ import filterArrayByHash from 'utilities/filter_array_by_hash';
|
|||
import Icon from 'components/icons/Icon';
|
||||
import InputField from 'components/forms/fields/InputField';
|
||||
import QueryResultsRow from 'components/queries/QueryResultsTable/QueryResultsRow';
|
||||
import QueryProgressDetails from 'components/queries/QueryProgressDetails';
|
||||
import Spinner from 'components/loaders/Spinner';
|
||||
|
||||
const baseClass = 'query-results-table';
|
||||
|
||||
|
@ -15,12 +17,22 @@ class QueryResultsTable extends Component {
|
|||
static propTypes = {
|
||||
campaign: campaignInterface.isRequired,
|
||||
onExportQueryResults: PropTypes.func,
|
||||
onToggleQueryFullScreen: PropTypes.func,
|
||||
isQueryFullScreen: PropTypes.bool,
|
||||
isQueryShrinking: PropTypes.bool,
|
||||
onRunQuery: PropTypes.func.isRequired,
|
||||
onStopQuery: PropTypes.func.isRequired,
|
||||
query: PropTypes.string,
|
||||
queryIsRunning: PropTypes.bool,
|
||||
queryTimerMilliseconds: PropTypes.number,
|
||||
};
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = { resultsFilter: {} };
|
||||
this.state = {
|
||||
resultsFilter: {},
|
||||
};
|
||||
}
|
||||
|
||||
onFilterAttribute = (attribute) => {
|
||||
|
@ -100,44 +112,93 @@ class QueryResultsTable extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
const { campaign, onExportQueryResults } = this.props;
|
||||
renderTable = () => {
|
||||
const {
|
||||
renderTableHeaderRow,
|
||||
renderTableRows,
|
||||
} = this;
|
||||
const { hosts_count: hostsCount } = campaign;
|
||||
|
||||
if (!hostsCount || !hostsCount.total) {
|
||||
return false;
|
||||
}
|
||||
const { queryIsRunning, campaign } = this.props;
|
||||
const { query_results: queryResults } = campaign;
|
||||
|
||||
if (!hostsCount.successful) {
|
||||
return (
|
||||
<div className={`${baseClass} ${baseClass}__no-results`}>
|
||||
<em>No results found</em>
|
||||
</div>
|
||||
);
|
||||
const loading = queryIsRunning && (!queryResults || !queryResults.length);
|
||||
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Button
|
||||
className={`${baseClass}__export-btn`}
|
||||
onClick={onExportQueryResults}
|
||||
variant="link"
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<table className={`${baseClass}__table`}>
|
||||
<thead>
|
||||
{renderTableHeaderRow()}
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderTableRows()}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
campaign,
|
||||
onExportQueryResults,
|
||||
isQueryFullScreen,
|
||||
isQueryShrinking,
|
||||
onToggleQueryFullScreen,
|
||||
onRunQuery,
|
||||
onStopQuery,
|
||||
query,
|
||||
queryIsRunning,
|
||||
queryTimerMilliseconds,
|
||||
} = this.props;
|
||||
|
||||
const { renderTable } = this;
|
||||
|
||||
const { hosts_count: hostsCount, query_results: queryResults } = campaign;
|
||||
const hasNoResults = !queryIsRunning && (!hostsCount.successful || (!queryResults || !queryResults.length));
|
||||
|
||||
const resultsTableWrapClass = classnames(baseClass, {
|
||||
[`${baseClass}--full-screen`]: isQueryFullScreen,
|
||||
[`${baseClass}--shrinking`]: isQueryShrinking,
|
||||
[`${baseClass}__no-results`]: hasNoResults,
|
||||
});
|
||||
|
||||
const toggleFullScreenBtnClass = classnames(`${baseClass}__fullscreen-btn`, {
|
||||
[`${baseClass}__fullscreen-btn--active`]: isQueryFullScreen,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={resultsTableWrapClass}>
|
||||
<header className={`${baseClass}__button-wrap`}>
|
||||
{isQueryFullScreen && <QueryProgressDetails
|
||||
campaign={campaign}
|
||||
onRunQuery={onRunQuery}
|
||||
onStopQuery={onStopQuery}
|
||||
query={query}
|
||||
queryIsRunning={queryIsRunning}
|
||||
className={`${baseClass}__full-screen`}
|
||||
queryTimerMilliseconds={queryTimerMilliseconds}
|
||||
/>}
|
||||
|
||||
<Button
|
||||
className={toggleFullScreenBtnClass}
|
||||
onClick={onToggleQueryFullScreen}
|
||||
variant="muted"
|
||||
>
|
||||
<Icon name={isQueryFullScreen ? 'windowed' : 'fullscreen'} />
|
||||
</Button>
|
||||
<Button
|
||||
className={`${baseClass}__export-btn`}
|
||||
onClick={onExportQueryResults}
|
||||
variant="link"
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</header>
|
||||
<div className={`${baseClass}__table-wrapper`}>
|
||||
<table className={`${baseClass}__table`}>
|
||||
<thead>
|
||||
{renderTableHeaderRow()}
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderTableRows()}
|
||||
</tbody>
|
||||
</table>
|
||||
{hasNoResults && <em className="no-results-message">No results found</em>}
|
||||
{!hasNoResults && renderTable()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -74,8 +74,17 @@ describe('QueryResultsTable - component', () => {
|
|||
expect(componentWithQueryResults.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('does not return HTML when there are no query results', () => {
|
||||
expect(componentWithoutQueryResults.html()).toNotExist();
|
||||
it('renders a QueryProgressDetails component if Results is Fullscreen', () => {
|
||||
const component = mount(<QueryResultsTable campaign={campaignWithQueryResults} isQueryFullScreen />);
|
||||
const QueryProgressDetails = component.find('QueryProgressDetails');
|
||||
|
||||
expect(QueryProgressDetails.length).toEqual(1, 'QueryProgressDetails did not render');
|
||||
});
|
||||
|
||||
it('doesn\'t render a QueryProgressDetails component if Results isn\'t Fullscreen', () => {
|
||||
const QueryProgressDetails = componentWithQueryResults.find('QueryProgressDetails');
|
||||
|
||||
expect(QueryProgressDetails.length).toEqual(0, 'QueryProgressDetails did not render');
|
||||
});
|
||||
|
||||
it('sets the column headers to the keys of the query results', () => {
|
||||
|
@ -105,7 +114,7 @@ describe('QueryResultsTable - component', () => {
|
|||
const spy = createSpy();
|
||||
const component = mount(<QueryResultsTable campaign={campaignWithQueryResults} onExportQueryResults={spy} />);
|
||||
|
||||
const exportBtn = component.find('Button');
|
||||
const exportBtn = component.find('.query-results-table__export-btn');
|
||||
|
||||
expect(spy).toNotHaveBeenCalled();
|
||||
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
.query-results-table {
|
||||
@include display(flex);
|
||||
@include flex-direction(column);
|
||||
background-color: $white;
|
||||
padding: $pad-base;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__button-wrap {
|
||||
@include clearfix;
|
||||
}
|
||||
|
||||
&__export-btn {
|
||||
float: right;
|
||||
}
|
||||
|
@ -14,25 +20,31 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
@include display(flex);
|
||||
@include align-items(center);
|
||||
@include justify-content(center);
|
||||
}
|
||||
|
||||
&__progress-details {
|
||||
display: inline-block;
|
||||
width: 378px;
|
||||
}
|
||||
|
||||
&__table-wrapper {
|
||||
@include display(flex);
|
||||
@include flex-grow(1);
|
||||
border: solid 1px $accent-dark;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12);
|
||||
overflow: scroll;
|
||||
margin-top: 58px;
|
||||
max-height: 550px;
|
||||
margin-top: 30px;
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
|
||||
.kolide-spinner {
|
||||
@include align-self(center);
|
||||
}
|
||||
|
||||
.no-results-message {
|
||||
@include flex-grow(1);
|
||||
@include align-self(center);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__table {
|
||||
|
@ -78,4 +90,50 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--full-screen {
|
||||
animation: growFullScreen 500ms;
|
||||
animation-fill-mode: forwards;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 12px 0 rgba(0, 0, 0, 0.1);
|
||||
border: solid 1px $silver;
|
||||
z-index: 99;
|
||||
|
||||
.query-progress-details__run-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--shrinking {
|
||||
animation: shrinkFullScreen 500ms;
|
||||
animation-fill-mode: forwards;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
&__fullscreen-btn {
|
||||
float: right;
|
||||
margin-left: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(growFullScreen) {
|
||||
100% {
|
||||
top: $pad-half;
|
||||
right: $pad-half;
|
||||
bottom: $pad-half;
|
||||
left: calc(#{$nav-tablet-width} + #{$pad-half});
|
||||
max-width: calc(100vw - #{$nav-tablet-width} - #{$pad-half} - #{$pad-half});
|
||||
max-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(shrinkFullScreen) {
|
||||
0% {
|
||||
top: $pad-half;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: $pad-half;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,10 @@
|
|||
text-align: left;
|
||||
border-radius: 0;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -34,6 +38,10 @@
|
|||
border: 1px solid $accent-medium;
|
||||
border-radius: 50%;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
margin: 24px 5px 10px;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
margin: 24px 5px 10px;
|
||||
}
|
||||
|
@ -50,6 +58,11 @@
|
|||
display: block;
|
||||
color: $text-ultradark;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
display: none;
|
||||
margin: 24px 0 5px 63px;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: none;
|
||||
margin: 24px 0 5px 63px;
|
||||
|
@ -68,6 +81,10 @@
|
|||
background-color: $success;
|
||||
}
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: none;
|
||||
}
|
||||
|
@ -83,6 +100,10 @@
|
|||
position: relative;
|
||||
line-height: 16px;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: none;
|
||||
}
|
||||
|
@ -93,6 +114,10 @@
|
|||
color: $accent-medium;
|
||||
font-size: 9px;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,13 @@
|
|||
margin-right: $pad-medium;
|
||||
vertical-align: sub;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-right: 0;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: block;
|
||||
text-align: center;
|
||||
|
@ -22,6 +29,10 @@
|
|||
font-weight: $normal;
|
||||
font-size: 14px;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: none;
|
||||
}
|
||||
|
@ -43,6 +54,10 @@
|
|||
color: $text-dark;
|
||||
}
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
height: 50px;
|
||||
}
|
||||
|
@ -66,6 +81,17 @@
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
border-bottom: 6px solid #9a61c6;
|
||||
border-left: 6px solid #9a61c6;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
border-bottom: 6px solid #9a61c6;
|
||||
border-left: 6px solid #9a61c6;
|
||||
|
@ -78,12 +104,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
.site-nav-item__icon {
|
||||
.site-nav-item__icon {
|
||||
@at-root .site-nav--small & {
|
||||
@include transform(translate(-2px, 3px));
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
@include transform(translate(-2px, 3px));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.site-nav-item--single {
|
||||
.site-nav-item__button {
|
||||
&::before {
|
||||
|
@ -97,6 +129,10 @@
|
|||
border-top: 1px solid $accent-light;
|
||||
padding-top: 15px;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
@ -137,6 +173,10 @@
|
|||
top: 50%;
|
||||
margin: -3px 0 0 -4px;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: none;
|
||||
}
|
||||
|
@ -158,6 +198,12 @@
|
|||
color: rgba($white, 0.9);
|
||||
}
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
text-align: center;
|
||||
padding: 3px 0 9px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
text-align: center;
|
||||
padding: 3px 0 9px;
|
||||
|
@ -176,6 +222,10 @@
|
|||
}
|
||||
|
||||
&__name {
|
||||
@at-root .site-nav--small & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(desktop) {
|
||||
display: inline-block;
|
||||
}
|
||||
|
@ -186,6 +236,15 @@
|
|||
}
|
||||
|
||||
&__icon {
|
||||
@at-root .site-nav--small & {
|
||||
display: inline-block;
|
||||
font-size: 25px;
|
||||
|
||||
.kolidecon {
|
||||
vertical-align: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint(desktop) {
|
||||
display: none;
|
||||
}
|
||||
|
@ -200,8 +259,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
&:first-child {
|
||||
&:first-child {
|
||||
@at-root .site-nav--small & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -216,6 +279,11 @@
|
|||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
@include linear-gradient(to bottom, #9a61c6 0%, $brand 18%, $brand 82%, #9a61c6 100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
@include linear-gradient(to bottom, #9a61c6 0%, $brand 18%, $brand 82%, #9a61c6 100%);
|
||||
box-shadow: none;
|
||||
|
@ -225,6 +293,12 @@
|
|||
padding: 12px 0;
|
||||
margin: 0;
|
||||
|
||||
@at-root .site-nav--small & {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
|
@ -232,6 +306,10 @@
|
|||
}
|
||||
|
||||
&--expanded {
|
||||
@at-root .site-nav--small & {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { connect } from 'react-redux';
|
|||
import LoadingBar from 'react-redux-loading-bar';
|
||||
import { logoutUser } from 'redux/nodes/auth/actions';
|
||||
import { push } from 'react-router-redux';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import configInterface from 'interfaces/config';
|
||||
import FlashMessage from 'components/flash_messages/FlashMessage';
|
||||
|
@ -20,6 +21,7 @@ export class CoreLayout extends Component {
|
|||
dispatch: PropTypes.func,
|
||||
user: userInterface,
|
||||
fullWidthFlash: PropTypes.bool,
|
||||
isSmallNav: PropTypes.bool,
|
||||
notifications: notificationInterface,
|
||||
persistentFlash: PropTypes.shape({
|
||||
showFlash: PropTypes.bool.isRequired,
|
||||
|
@ -75,7 +77,15 @@ export class CoreLayout extends Component {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { fullWidthFlash, notifications, children, config, persistentFlash, user } = this.props;
|
||||
const {
|
||||
fullWidthFlash,
|
||||
notifications,
|
||||
children,
|
||||
config,
|
||||
persistentFlash,
|
||||
user,
|
||||
isSmallNav,
|
||||
} = this.props;
|
||||
const { onRemoveFlash, onUndoActionClick } = this;
|
||||
|
||||
if (!user) return false;
|
||||
|
@ -83,10 +93,18 @@ export class CoreLayout extends Component {
|
|||
const { onLogoutUser, onNavItemClick } = this;
|
||||
const { pathname } = global.window.location;
|
||||
|
||||
const siteNavClasses = classnames('site-nav', {
|
||||
'site-nav--small': isSmallNav,
|
||||
});
|
||||
|
||||
const coreWrapperClasses = classnames('core-wrapper', {
|
||||
'core-wrapper--small': isSmallNav,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="app-wrap">
|
||||
<LoadingBar />
|
||||
<nav className="site-nav">
|
||||
<nav className={siteNavClasses}>
|
||||
<SiteNavHeader
|
||||
config={config}
|
||||
onLogoutUser={onLogoutUser}
|
||||
|
@ -100,7 +118,7 @@ export class CoreLayout extends Component {
|
|||
user={user}
|
||||
/>
|
||||
</nav>
|
||||
<div className="core-wrapper">
|
||||
<div className={coreWrapperClasses}>
|
||||
{persistentFlash.showFlash && <PersistentFlash message={persistentFlash.message} />}
|
||||
<FlashMessage
|
||||
fullWidth={fullWidthFlash}
|
||||
|
@ -117,7 +135,10 @@ export class CoreLayout extends Component {
|
|||
|
||||
const mapStateToProps = (state) => {
|
||||
const {
|
||||
app: { config },
|
||||
app: {
|
||||
config,
|
||||
isSmallNav,
|
||||
},
|
||||
auth: { user },
|
||||
notifications,
|
||||
persistentFlash,
|
||||
|
@ -128,6 +149,7 @@ const mapStateToProps = (state) => {
|
|||
return {
|
||||
config,
|
||||
fullWidthFlash,
|
||||
isSmallNav,
|
||||
notifications,
|
||||
persistentFlash,
|
||||
user,
|
||||
|
|
|
@ -10,6 +10,10 @@
|
|||
max-width: calc(100% - 258px);
|
||||
position: relative;
|
||||
|
||||
&--small {
|
||||
max-width: calc(100% - 73px);
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
max-width: calc(100% - 73px);
|
||||
}
|
||||
|
@ -22,6 +26,11 @@
|
|||
box-sizing: border-box;
|
||||
width: $nav-width;
|
||||
|
||||
&--small {
|
||||
padding-left: 0;
|
||||
width: $nav-tablet-width;
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
padding-left: 0;
|
||||
width: $nav-tablet-width;
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { Component, PropTypes } from 'react';
|
|||
import classnames from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import FileSaver from 'file-saver';
|
||||
import { filter, includes, isArray, isEqual } from 'lodash';
|
||||
import { clone, filter, includes, isArray, isEqual, merge } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { push } from 'react-router-redux';
|
||||
|
||||
|
@ -22,10 +22,10 @@ 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';
|
||||
import { toggleSmallNav } from 'redux/nodes/app/actions';
|
||||
import { selectOsqueryTable, setSelectedTargets, setSelectedTargetsQuery } from 'redux/nodes/components/QueryPages/actions';
|
||||
import targetInterface from 'interfaces/target';
|
||||
import validateQuery from 'components/forms/validators/validate_query';
|
||||
import Spinner from 'components/loaders/Spinner';
|
||||
|
||||
const baseClass = 'query-page';
|
||||
const DEFAULT_CAMPAIGN = {
|
||||
|
@ -34,6 +34,11 @@ const DEFAULT_CAMPAIGN = {
|
|||
},
|
||||
};
|
||||
|
||||
const QUERY_RESULTS_OPTIONS = {
|
||||
FULL_SCREEN: 'FULL_SCREEN',
|
||||
SHRINKING: 'SHRINKING',
|
||||
};
|
||||
|
||||
export class QueryPage extends Component {
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func,
|
||||
|
@ -61,8 +66,11 @@ export class QueryPage extends Component {
|
|||
campaign: DEFAULT_CAMPAIGN,
|
||||
queryIsRunning: false,
|
||||
queryText: props.query.query,
|
||||
runQueryMilliseconds: 0,
|
||||
targetsCount: 0,
|
||||
targetsError: null,
|
||||
queryResultsToggle: null,
|
||||
queryPosition: {},
|
||||
};
|
||||
|
||||
this.csvQueryName = 'Query Results';
|
||||
|
@ -177,9 +185,11 @@ export class QueryPage extends Component {
|
|||
.then((campaignResponse) => {
|
||||
return Kolide.websockets.queries.run(campaignResponse.id)
|
||||
.then((socket) => {
|
||||
this.setState({ campaign: campaignResponse });
|
||||
this.socket = socket;
|
||||
this.setState({ queryIsRunning: true });
|
||||
this.setupDistributedQuery(socket);
|
||||
this.setState({
|
||||
campaign: campaignResponse,
|
||||
queryIsRunning: true,
|
||||
});
|
||||
|
||||
this.socket.onmessage = ({ data }) => {
|
||||
const socketData = JSON.parse(data);
|
||||
|
@ -196,8 +206,7 @@ export class QueryPage extends Component {
|
|||
const { status } = updatedCampaign;
|
||||
|
||||
if (status === 'finished') {
|
||||
this.setState({ queryIsRunning: false });
|
||||
removeSocket();
|
||||
this.teardownDistributedQuery();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
@ -245,11 +254,9 @@ export class QueryPage extends Component {
|
|||
onStopQuery = (evt) => {
|
||||
evt.preventDefault();
|
||||
|
||||
const { removeSocket } = this;
|
||||
const { teardownDistributedQuery } = this;
|
||||
|
||||
this.setState({ queryIsRunning: false });
|
||||
|
||||
return removeSocket();
|
||||
return teardownDistributedQuery();
|
||||
}
|
||||
|
||||
onTargetSelect = (selectedTargets) => {
|
||||
|
@ -274,6 +281,110 @@ export class QueryPage extends Component {
|
|||
return false;
|
||||
};
|
||||
|
||||
onToggleQueryFullScreen = (evt) => {
|
||||
const { document: { body }, window } = global;
|
||||
const { queryResultsToggle, queryPosition } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
window.scrollTo(0, 0);
|
||||
const { parentNode: { parentNode: parent } } = evt.currentTarget;
|
||||
const { parentNode: grandParent } = parent;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
|
||||
const defaultPosition = {
|
||||
top: `${rect.top}px`,
|
||||
left: `${rect.left}px`,
|
||||
right: `${rect.right - rect.left}px`,
|
||||
bottom: `${rect.bottom - rect.top}px`,
|
||||
maxWidth: `${parent.offsetWidth}px`,
|
||||
minWidth: `${parent.offsetWidth}px`,
|
||||
maxHeight: `${parent.offsetHeight}px`,
|
||||
minHeight: `${parent.offsetHeight}px`,
|
||||
position: 'fixed',
|
||||
};
|
||||
|
||||
const resetPosition = {
|
||||
position: 'static',
|
||||
maxWidth: 'auto',
|
||||
minWidth: 'auto',
|
||||
maxHeight: 'auto',
|
||||
minHeight: 'auto',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
left: 'auto',
|
||||
};
|
||||
|
||||
let newPosition = clone(defaultPosition);
|
||||
let newState;
|
||||
let callback;
|
||||
|
||||
if (queryResultsToggle !== QUERY_RESULTS_OPTIONS.FULL_SCREEN) {
|
||||
newState = {
|
||||
queryResultsToggle: QUERY_RESULTS_OPTIONS.FULL_SCREEN,
|
||||
queryPosition: defaultPosition,
|
||||
};
|
||||
|
||||
callback = () => {
|
||||
body.style.overflow = 'hidden';
|
||||
dispatch(toggleSmallNav);
|
||||
merge(parent.style, newPosition);
|
||||
grandParent.style.height = `${newPosition.maxHeight}`;
|
||||
};
|
||||
} else {
|
||||
newState = {
|
||||
queryResultsToggle: QUERY_RESULTS_OPTIONS.SHRINKING,
|
||||
};
|
||||
|
||||
callback = () => {
|
||||
body.style.overflow = 'visible';
|
||||
dispatch(toggleSmallNav);
|
||||
newPosition = queryPosition;
|
||||
merge(parent.style, newPosition);
|
||||
grandParent.style.height = `${newPosition.maxHeight}`;
|
||||
|
||||
window.setTimeout(() => {
|
||||
merge(parent.style, resetPosition);
|
||||
}, 500);
|
||||
};
|
||||
}
|
||||
|
||||
this.setState(newState, callback);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
setupDistributedQuery = (socket) => {
|
||||
this.socket = socket;
|
||||
const update = () => {
|
||||
const { runQueryMilliseconds } = this.state;
|
||||
|
||||
this.setState({ runQueryMilliseconds: runQueryMilliseconds + 1000 });
|
||||
};
|
||||
|
||||
if (!this.runQueryInterval) {
|
||||
this.runQueryInterval = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
teardownDistributedQuery = () => {
|
||||
const { runQueryInterval } = this;
|
||||
|
||||
if (runQueryInterval) {
|
||||
clearInterval(runQueryInterval);
|
||||
this.runQueryInterval = null;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
queryIsRunning: false,
|
||||
runQueryMilliseconds: 0,
|
||||
});
|
||||
this.removeSocket();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
destroyCampaign = () => {
|
||||
const { campaign } = this.state;
|
||||
|
||||
|
@ -307,34 +418,48 @@ export class QueryPage extends Component {
|
|||
}
|
||||
|
||||
renderResultsTable = () => {
|
||||
const { campaign, queryIsRunning } = this.state;
|
||||
const { onExportQueryResults } = this;
|
||||
const {
|
||||
campaign,
|
||||
queryIsRunning,
|
||||
queryResultsToggle,
|
||||
queryText,
|
||||
runQueryMilliseconds,
|
||||
} = this.state;
|
||||
const { onExportQueryResults, onToggleQueryFullScreen, onRunQuery, onStopQuery, onTargetSelect } = this;
|
||||
const loading = queryIsRunning && !campaign.hosts_count.total;
|
||||
const isQueryFullScreen = queryResultsToggle === QUERY_RESULTS_OPTIONS.FULL_SCREEN;
|
||||
const isQueryShrinking = queryResultsToggle === QUERY_RESULTS_OPTIONS.SHRINKING;
|
||||
const resultsClasses = classnames(`${baseClass}__results`, 'body-wrap', {
|
||||
[`${baseClass}__results--loading`]: loading,
|
||||
[`${baseClass}__results--full-screen`]: isQueryFullScreen,
|
||||
});
|
||||
let resultBody = '';
|
||||
|
||||
if (!loading && isEqual(campaign, DEFAULT_CAMPAIGN)) {
|
||||
if (isEqual(campaign, DEFAULT_CAMPAIGN)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
resultBody = <Spinner />;
|
||||
} else {
|
||||
resultBody = <QueryResultsTable campaign={campaign} onExportQueryResults={onExportQueryResults} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={resultsClasses}>
|
||||
{resultBody}
|
||||
<QueryResultsTable
|
||||
campaign={campaign}
|
||||
onExportQueryResults={onExportQueryResults}
|
||||
isQueryFullScreen={isQueryFullScreen}
|
||||
isQueryShrinking={isQueryShrinking}
|
||||
onToggleQueryFullScreen={onToggleQueryFullScreen}
|
||||
onRunQuery={onRunQuery}
|
||||
onStopQuery={onStopQuery}
|
||||
onTargetSelect={onTargetSelect}
|
||||
query={queryText}
|
||||
queryIsRunning={queryIsRunning}
|
||||
queryTimerMilliseconds={runQueryMilliseconds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderTargetsInput = () => {
|
||||
const { onFetchTargets, onRunQuery, onStopQuery, onTargetSelect } = this;
|
||||
const { campaign, queryIsRunning, queryText, targetsCount, targetsError } = this.state;
|
||||
const { campaign, queryIsRunning, queryText, targetsCount, targetsError, runQueryMilliseconds } = this.state;
|
||||
const { selectedTargets } = this.props;
|
||||
|
||||
return (
|
||||
|
@ -349,6 +474,7 @@ export class QueryPage extends Component {
|
|||
queryIsRunning={queryIsRunning}
|
||||
selectedTargets={selectedTargets}
|
||||
targetsCount={targetsCount}
|
||||
queryTimerMilliseconds={runQueryMilliseconds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -114,7 +114,7 @@ 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 QueryPageSelectTargets = page.find('QueryPageSelectTargets');
|
||||
const runQueryBtn = page.find('.query-page-select-targets__run-query-btn');
|
||||
const runQueryBtn = page.find('.query-progress-details__run-btn');
|
||||
|
||||
expect(QueryPageSelectTargets.prop('error')).toNotExist();
|
||||
|
||||
|
@ -213,9 +213,41 @@ describe('QueryPage - component', () => {
|
|||
|
||||
const QueryResultsTable = Page.find('QueryResultsTable');
|
||||
|
||||
QueryResultsTable.find('Button').simulate('click');
|
||||
QueryResultsTable.find('.query-results-table__export-btn').simulate('click');
|
||||
|
||||
expect(fileSaveSpy).toHaveBeenCalledWith(fileStub);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle full screen results', () => {
|
||||
it('toggles query results table from default to full screen and back', () => {
|
||||
const queryResult = { org_name: 'Kolide', org_url: 'https://kolide.co' };
|
||||
const campaign = {
|
||||
id: 1,
|
||||
hosts_count: {
|
||||
failed: 0,
|
||||
successful: 1,
|
||||
total: 1,
|
||||
},
|
||||
query_results: [queryResult],
|
||||
};
|
||||
const Page = mount(<QueryPage dispatch={noop} query={queryStub} selectedOsqueryTable={defaultSelectedOsqueryTable} />);
|
||||
Page.setState({ campaign });
|
||||
|
||||
const QueryResultsTable = Page.find('QueryResultsTable');
|
||||
|
||||
QueryResultsTable.find('.query-results-table__fullscreen-btn').simulate('click');
|
||||
|
||||
expect(QueryResultsTable.find('.query-results-table__fullscreen-btn--active').length).toEqual(1);
|
||||
expect(QueryResultsTable.find('.query-results-table--full-screen').length).toEqual(1);
|
||||
expect(Page.find('.query-page__results--full-screen').length).toEqual(1);
|
||||
|
||||
QueryResultsTable.find('.query-results-table__fullscreen-btn').simulate('click');
|
||||
|
||||
expect(QueryResultsTable.find('.query-results-table__fullscreen-btn--active').length).toEqual(0);
|
||||
expect(QueryResultsTable.find('.query-results-table--full-screen').length).toEqual(0);
|
||||
expect(QueryResultsTable.find('.query-results-table--shrinking').length).toEqual(1);
|
||||
expect(Page.find('.query-page__results--full-screen').length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,10 +9,7 @@
|
|||
&__results {
|
||||
@include display(flex);
|
||||
@include flex-grow(1);
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
|
||||
&--loading {
|
||||
@include align-items(center);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ export const CONFIG_START = 'CONFIG_START';
|
|||
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
|
||||
export const SHOW_BACKGROUND_IMAGE = 'SHOW_BACKGROUND_IMAGE';
|
||||
export const HIDE_BACKGROUND_IMAGE = 'HIDE_BACKGROUND_IMAGE';
|
||||
export const TOGGLE_SMALL_NAV = 'TOGGLE_SMALL_NAV';
|
||||
|
||||
export const showBackgroundImage = {
|
||||
type: SHOW_BACKGROUND_IMAGE,
|
||||
|
@ -15,6 +16,9 @@ export const showBackgroundImage = {
|
|||
export const hideBackgroundImage = {
|
||||
type: HIDE_BACKGROUND_IMAGE,
|
||||
};
|
||||
export const toggleSmallNav = {
|
||||
type: TOGGLE_SMALL_NAV,
|
||||
};
|
||||
export const configFailure = (error) => {
|
||||
return { type: CONFIG_FAILURE, payload: { error } };
|
||||
};
|
||||
|
|
|
@ -4,11 +4,13 @@ import {
|
|||
CONFIG_SUCCESS,
|
||||
HIDE_BACKGROUND_IMAGE,
|
||||
SHOW_BACKGROUND_IMAGE,
|
||||
TOGGLE_SMALL_NAV,
|
||||
} from './actions';
|
||||
|
||||
export const initialState = {
|
||||
config: {},
|
||||
error: {},
|
||||
isSmallNav: false,
|
||||
loading: false,
|
||||
showBackgroundImage: false,
|
||||
};
|
||||
|
@ -43,6 +45,11 @@ const reducer = (state = initialState, { type, payload }) => {
|
|||
...state,
|
||||
showBackgroundImage: true,
|
||||
};
|
||||
case TOGGLE_SMALL_NAV:
|
||||
return {
|
||||
...state,
|
||||
isSmallNav: !state.isSmallNav,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
configSuccess,
|
||||
hideBackgroundImage,
|
||||
showBackgroundImage,
|
||||
toggleSmallNav,
|
||||
loadConfig,
|
||||
} from './actions';
|
||||
|
||||
|
@ -14,12 +15,31 @@ describe('App - reducer', () => {
|
|||
expect(reducer(undefined, { type: 'SOME_ACTION' })).toEqual(initialState);
|
||||
});
|
||||
|
||||
context('toggleSmallNav action', () => {
|
||||
it('toggles isSmallNav on', () => {
|
||||
expect(reducer(initialState, toggleSmallNav)).toEqual({
|
||||
...initialState,
|
||||
isSmallNav: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles isSmallNav off', () => {
|
||||
const state = {
|
||||
...initialState,
|
||||
isSmallNav: true,
|
||||
};
|
||||
|
||||
expect(reducer(state, toggleSmallNav)).toEqual({
|
||||
...state,
|
||||
isSmallNav: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
context('showBackgroundImage action', () => {
|
||||
it('shows the background image', () => {
|
||||
expect(reducer(initialState, showBackgroundImage)).toEqual({
|
||||
config: {},
|
||||
error: {},
|
||||
loading: false,
|
||||
...initialState,
|
||||
showBackgroundImage: true,
|
||||
});
|
||||
});
|
||||
|
@ -32,9 +52,7 @@ describe('App - reducer', () => {
|
|||
showBackgroundImage: true,
|
||||
};
|
||||
expect(reducer(state, hideBackgroundImage)).toEqual({
|
||||
config: {},
|
||||
error: {},
|
||||
loading: false,
|
||||
...state,
|
||||
showBackgroundImage: false,
|
||||
});
|
||||
});
|
||||
|
@ -43,10 +61,8 @@ describe('App - reducer', () => {
|
|||
context('loadConfig action', () => {
|
||||
it('sets the state to loading', () => {
|
||||
expect(reducer(initialState, loadConfig)).toEqual({
|
||||
config: {},
|
||||
error: {},
|
||||
...initialState,
|
||||
loading: true,
|
||||
showBackgroundImage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -62,6 +78,7 @@ describe('App - reducer', () => {
|
|||
config,
|
||||
error: {},
|
||||
loading: false,
|
||||
isSmallNav: false,
|
||||
showBackgroundImage: false,
|
||||
});
|
||||
});
|
||||
|
@ -78,6 +95,7 @@ describe('App - reducer', () => {
|
|||
config: {},
|
||||
error,
|
||||
loading: false,
|
||||
isSmallNav: false,
|
||||
showBackgroundImage: false,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -67,18 +67,28 @@ a {
|
|||
margin-top: $pad-base;
|
||||
margin-right: $pad-base;
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
max-width: calc(100vw - #{$nav-tablet-width} - #{$pad-base});
|
||||
@at-root {
|
||||
.core-wrapper--small & {
|
||||
max-width: calc(100vw - #{$nav-tablet-width} - #{$pad-base});
|
||||
}
|
||||
|
||||
.has-sidebar & {
|
||||
margin-right: 0;
|
||||
min-width: 610px;
|
||||
max-width: calc(100vw - #{$nav-width} - #{$pad-base} - #{$pad-base} - #{$sidepanel-width});
|
||||
|
||||
@at-root .core-wrapper--small & {
|
||||
max-width: calc(100vw - #{$nav-tablet-width} - #{$pad-base} - #{$pad-base} - #{$sidepanel-tablet-width});
|
||||
}
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
max-width: calc(100vw - #{$nav-tablet-width} - #{$pad-base} - #{$pad-base} - #{$sidepanel-tablet-width});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@at-root .has-sidebar & {
|
||||
margin-right: 0;
|
||||
min-width: 610px;
|
||||
max-width: calc(100vw - #{$nav-width} - #{$pad-base} - #{$pad-base} - #{$sidepanel-width});
|
||||
|
||||
@include breakpoint(smalldesk) {
|
||||
max-width: calc(100vw - #{$nav-tablet-width} - #{$pad-base} - #{$pad-base} - #{$sidepanel-tablet-width});
|
||||
}
|
||||
@include breakpoint(smalldesk) {
|
||||
max-width: calc(100vw - #{$nav-tablet-width} - #{$pad-base});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,354 +14,376 @@
|
|||
}
|
||||
|
||||
.kolidecon-lg {
|
||||
font-size: 1.33333333em;
|
||||
line-height: 0.75em;
|
||||
vertical-align: -15%;
|
||||
}
|
||||
|
||||
.kolidecon-2x {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.kolidecon-3x {
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
.kolidecon-4x {
|
||||
font-size: 4em;
|
||||
}
|
||||
|
||||
.kolidecon-5x {
|
||||
font-size: 5em;
|
||||
}
|
||||
|
||||
.kolidecon-fw {
|
||||
width: 1.28571429em;
|
||||
text-align: center;
|
||||
}
|
||||
font-size: 1.33333333em;
|
||||
line-height: 0.75em;
|
||||
vertical-align: -15%;
|
||||
}
|
||||
|
||||
.kolidecon-kolide-logo-flat:before {
|
||||
content: '\f000';
|
||||
}
|
||||
.kolidecon-2x {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.kolidecon-chevrondown:before {
|
||||
content: '\f004';
|
||||
}
|
||||
.kolidecon-3x {
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
.kolidecon-chevronleft:before {
|
||||
content: '\f006';
|
||||
}
|
||||
.kolidecon-4x {
|
||||
font-size: 4em;
|
||||
}
|
||||
|
||||
.kolidecon-chevronright:before {
|
||||
content: '\f008';
|
||||
}
|
||||
.kolidecon-5x {
|
||||
font-size: 5em;
|
||||
}
|
||||
|
||||
.kolidecon-chevronup:before {
|
||||
content: '\f00a';
|
||||
}
|
||||
|
||||
.kolidecon-cpu:before {
|
||||
content: '\f00c';
|
||||
}
|
||||
|
||||
.kolidecon-downcarat:before {
|
||||
content: '\f00d';
|
||||
}
|
||||
.kolidecon-fw {
|
||||
width: 1.28571429em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kolidecon-filter:before {
|
||||
content: '\f00f';
|
||||
}
|
||||
|
||||
.kolidecon-mac:before {
|
||||
content: '\f012';
|
||||
}
|
||||
|
||||
.kolidecon-memory:before {
|
||||
content: '\f013';
|
||||
}
|
||||
|
||||
.kolidecon-storage:before {
|
||||
content: '\f019';
|
||||
}
|
||||
|
||||
.kolidecon-upcarat:before {
|
||||
content: '\f01b';
|
||||
}
|
||||
|
||||
.kolidecon-uptime:before {
|
||||
content: '\f01c';
|
||||
}
|
||||
|
||||
.kolidecon-world:before {
|
||||
content: '\f01d';
|
||||
}
|
||||
|
||||
.kolidecon-osquery:before {
|
||||
content: '\f021';
|
||||
}
|
||||
|
||||
.kolidecon-join:before {
|
||||
content: '\f022';
|
||||
}
|
||||
|
||||
.kolidecon-add-button:before {
|
||||
content: '\f029';
|
||||
}
|
||||
|
||||
.kolidecon-packs:before {
|
||||
content: '\f02f';
|
||||
}
|
||||
|
||||
.kolidecon-help:before {
|
||||
content: '\f030';
|
||||
}
|
||||
|
||||
.kolidecon-admin:before {
|
||||
content: '\f031';
|
||||
}
|
||||
.kolidecon-kolide-logo-flat:before {
|
||||
content: '\f000';
|
||||
}
|
||||
|
||||
.kolidecon-config:before {
|
||||
content: '\f032';
|
||||
}
|
||||
.kolidecon-chevrondown:before {
|
||||
content: '\f004';
|
||||
}
|
||||
|
||||
.kolidecon-mia:before {
|
||||
content: '\f034';
|
||||
}
|
||||
.kolidecon-chevronleft:before {
|
||||
content: '\f006';
|
||||
}
|
||||
|
||||
.kolidecon-success-check:before {
|
||||
content: '\f035';
|
||||
}
|
||||
.kolidecon-chevronright:before {
|
||||
content: '\f008';
|
||||
}
|
||||
|
||||
.kolidecon-offline:before {
|
||||
content: '\f036';
|
||||
}
|
||||
.kolidecon-chevronup:before {
|
||||
content: '\f00a';
|
||||
}
|
||||
|
||||
.kolidecon-windows:before {
|
||||
content: '\f037';
|
||||
}
|
||||
.kolidecon-cpu:before {
|
||||
content: '\f00c';
|
||||
}
|
||||
|
||||
.kolidecon-centos:before {
|
||||
content: '\f038';
|
||||
}
|
||||
.kolidecon-downcarat:before {
|
||||
content: '\f00d';
|
||||
}
|
||||
|
||||
.kolidecon-ubuntu:before {
|
||||
content: '\f039';
|
||||
}
|
||||
.kolidecon-filter:before {
|
||||
content: '\f00f';
|
||||
}
|
||||
|
||||
.kolidecon-apple:before {
|
||||
content: '\f03a';
|
||||
}
|
||||
.kolidecon-mac:before {
|
||||
content: '\f012';
|
||||
}
|
||||
|
||||
.kolidecon-search:before {
|
||||
content: '\f03b';
|
||||
}
|
||||
.kolidecon-memory:before {
|
||||
content: '\f013';
|
||||
}
|
||||
|
||||
.kolidecon-all-hosts:before {
|
||||
content: '\f03c';
|
||||
}
|
||||
.kolidecon-storage:before {
|
||||
content: '\f019';
|
||||
}
|
||||
|
||||
.kolidecon-alerts:before {
|
||||
content: '\f03e';
|
||||
}
|
||||
.kolidecon-upcarat:before {
|
||||
content: '\f01b';
|
||||
}
|
||||
|
||||
.kolidecon-logout:before {
|
||||
content: '\f03f';
|
||||
}
|
||||
.kolidecon-uptime:before {
|
||||
content: '\f01c';
|
||||
}
|
||||
|
||||
.kolidecon-user-settings:before {
|
||||
content: '\f040';
|
||||
}
|
||||
.kolidecon-world:before {
|
||||
content: '\f01d';
|
||||
}
|
||||
|
||||
.kolidecon-clipboard:before {
|
||||
content: '\f043';
|
||||
}
|
||||
.kolidecon-osquery:before {
|
||||
content: '\f021';
|
||||
}
|
||||
|
||||
.kolidecon-list-select:before {
|
||||
content: '\f044';
|
||||
}
|
||||
.kolidecon-join:before {
|
||||
content: '\f022';
|
||||
}
|
||||
|
||||
.kolidecon-grid-select:before {
|
||||
content: '\f045';
|
||||
}
|
||||
.kolidecon-add-button:before {
|
||||
content: '\f029';
|
||||
}
|
||||
|
||||
.kolidecon-label:before {
|
||||
content: '\f033';
|
||||
}
|
||||
.kolidecon-packs:before {
|
||||
content: '\f02f';
|
||||
}
|
||||
|
||||
.kolidecon-docker:before {
|
||||
content: '\f046';
|
||||
}
|
||||
.kolidecon-help:before {
|
||||
content: '\f030';
|
||||
}
|
||||
|
||||
.kolidecon-cloud:before {
|
||||
content: '\f047';
|
||||
}
|
||||
.kolidecon-admin:before {
|
||||
content: '\f031';
|
||||
}
|
||||
|
||||
.kolidecon-self-hosted:before {
|
||||
content: '\f048';
|
||||
}
|
||||
.kolidecon-config:before {
|
||||
content: '\f032';
|
||||
}
|
||||
|
||||
.kolidecon-help-solid:before {
|
||||
content: '\f049';
|
||||
}
|
||||
.kolidecon-mia:before {
|
||||
content: '\f034';
|
||||
}
|
||||
|
||||
.kolidecon-help-stroke:before {
|
||||
content: '\f04a';
|
||||
}
|
||||
.kolidecon-success-check:before {
|
||||
content: '\f035';
|
||||
}
|
||||
|
||||
.kolidecon-warning-filled:before {
|
||||
content: '\f04b';
|
||||
}
|
||||
.kolidecon-offline:before {
|
||||
content: '\f036';
|
||||
}
|
||||
|
||||
.kolidecon-delete-cloud:before {
|
||||
content: '\f04c';
|
||||
}
|
||||
.kolidecon-windows:before {
|
||||
content: '\f037';
|
||||
}
|
||||
|
||||
.kolidecon-pdf:before {
|
||||
content: '\f04d';
|
||||
}
|
||||
.kolidecon-centos:before {
|
||||
content: '\f038';
|
||||
}
|
||||
|
||||
.kolidecon-credit-card-small:before {
|
||||
content: '\f04e';
|
||||
}
|
||||
.kolidecon-ubuntu:before {
|
||||
content: '\f039';
|
||||
}
|
||||
|
||||
.kolidecon-billing-card:before {
|
||||
content: '\f04f';
|
||||
}
|
||||
.kolidecon-apple:before {
|
||||
content: '\f03a';
|
||||
}
|
||||
|
||||
.kolidecon-lock-big:before {
|
||||
content: '\f050';
|
||||
}
|
||||
.kolidecon-search:before {
|
||||
content: '\f03b';
|
||||
}
|
||||
|
||||
.kolidecon-link-big:before {
|
||||
content: '\f051';
|
||||
}
|
||||
.kolidecon-all-hosts:before {
|
||||
content: '\f03c';
|
||||
}
|
||||
|
||||
.kolidecon-briefcase:before {
|
||||
content: '\f052';
|
||||
}
|
||||
.kolidecon-alerts:before {
|
||||
content: '\f03e';
|
||||
}
|
||||
|
||||
.kolidecon-name-card:before {
|
||||
content: '\f053';
|
||||
}
|
||||
.kolidecon-logout:before {
|
||||
content: '\f03f';
|
||||
}
|
||||
|
||||
.kolidecon-kolide-logo:before {
|
||||
content: '\f054';
|
||||
}
|
||||
.kolidecon-user-settings:before {
|
||||
content: '\f040';
|
||||
}
|
||||
|
||||
.kolidecon-business:before {
|
||||
content: '\f055';
|
||||
}
|
||||
.kolidecon-clipboard:before {
|
||||
content: '\f043';
|
||||
}
|
||||
|
||||
.kolidecon-clock:before {
|
||||
content: '\f056';
|
||||
}
|
||||
.kolidecon-list-select:before {
|
||||
content: '\f044';
|
||||
}
|
||||
|
||||
.kolidecon-host-large:before {
|
||||
content: '\f057';
|
||||
}
|
||||
.kolidecon-grid-select:before {
|
||||
content: '\f045';
|
||||
}
|
||||
|
||||
.kolidecon-single-host:before {
|
||||
content: '\f03d';
|
||||
}
|
||||
.kolidecon-label:before {
|
||||
content: '\f033';
|
||||
}
|
||||
|
||||
.kolidecon-username:before {
|
||||
content: '\f02a';
|
||||
}
|
||||
.kolidecon-docker:before {
|
||||
content: '\f046';
|
||||
}
|
||||
|
||||
.kolidecon-password:before {
|
||||
content: '\f02b';
|
||||
}
|
||||
.kolidecon-cloud:before {
|
||||
content: '\f047';
|
||||
}
|
||||
|
||||
.kolidecon-email:before {
|
||||
content: '\f02c';
|
||||
}
|
||||
.kolidecon-self-hosted:before {
|
||||
content: '\f048';
|
||||
}
|
||||
|
||||
.kolidecon-hosts:before {
|
||||
content: '\f02e';
|
||||
}
|
||||
.kolidecon-help-solid:before {
|
||||
content: '\f049';
|
||||
}
|
||||
|
||||
.kolidecon-query:before {
|
||||
content: '\f02d';
|
||||
}
|
||||
.kolidecon-help-stroke:before {
|
||||
content: '\f04a';
|
||||
}
|
||||
|
||||
.kolidecon-import:before {
|
||||
content: '\f058';
|
||||
}
|
||||
.kolidecon-warning-filled:before {
|
||||
content: '\f04b';
|
||||
}
|
||||
|
||||
.kolidecon-pencil:before {
|
||||
content: '\f059';
|
||||
}
|
||||
.kolidecon-delete-cloud:before {
|
||||
content: '\f04c';
|
||||
}
|
||||
|
||||
.kolidecon-add-plus:before {
|
||||
content: '\f05a';
|
||||
}
|
||||
.kolidecon-pdf:before {
|
||||
content: '\f04d';
|
||||
}
|
||||
|
||||
.kolidecon-x:before {
|
||||
content: '\f05b';
|
||||
}
|
||||
.kolidecon-credit-card-small:before {
|
||||
content: '\f04e';
|
||||
}
|
||||
|
||||
.kolidecon-kill-kolide:before {
|
||||
content: '\f05c';
|
||||
}
|
||||
.kolidecon-billing-card:before {
|
||||
content: '\f04f';
|
||||
}
|
||||
|
||||
.kolidecon-right-arrow:before {
|
||||
content: '\f05d';
|
||||
}
|
||||
.kolidecon-lock-big:before {
|
||||
content: '\f050';
|
||||
}
|
||||
|
||||
.kolidecon-camera:before {
|
||||
content: '\f05e';
|
||||
}
|
||||
.kolidecon-link-big:before {
|
||||
content: '\f051';
|
||||
}
|
||||
|
||||
.kolidecon-plus-minus:before {
|
||||
content: '\f05f';
|
||||
}
|
||||
.kolidecon-briefcase:before {
|
||||
content: '\f052';
|
||||
}
|
||||
|
||||
.kolidecon-bold-plus:before {
|
||||
content: '\f060';
|
||||
}
|
||||
.kolidecon-name-card:before {
|
||||
content: '\f053';
|
||||
}
|
||||
|
||||
.kolidecon-linux:before {
|
||||
content: '\f061';
|
||||
}
|
||||
.kolidecon-kolide-logo:before {
|
||||
content: '\f054';
|
||||
}
|
||||
|
||||
.kolidecon-clock2:before {
|
||||
content: '\f063';
|
||||
}
|
||||
.kolidecon-business:before {
|
||||
content: '\f055';
|
||||
}
|
||||
|
||||
.kolidecon-trash:before {
|
||||
content: '\f064';
|
||||
}
|
||||
.kolidecon-clock:before {
|
||||
content: '\f056';
|
||||
}
|
||||
|
||||
.kolidecon-laptop-plus:before {
|
||||
content: '\f066';
|
||||
}
|
||||
.kolidecon-host-large:before {
|
||||
content: '\f057';
|
||||
}
|
||||
|
||||
.kolidecon-wrench-hand:before {
|
||||
content: '\f067';
|
||||
}
|
||||
.kolidecon-single-host:before {
|
||||
content: '\f03d';
|
||||
}
|
||||
|
||||
.kolidecon-external-link:before {
|
||||
content: '\f068';
|
||||
}
|
||||
.kolidecon-username:before {
|
||||
content: '\f02a';
|
||||
}
|
||||
|
||||
.kolidecon-password:before {
|
||||
content: '\f02b';
|
||||
}
|
||||
|
||||
.kolidecon-email:before {
|
||||
content: '\f02c';
|
||||
}
|
||||
|
||||
.kolidecon-hosts:before {
|
||||
content: '\f02e';
|
||||
}
|
||||
|
||||
.kolidecon-query:before {
|
||||
content: '\f02d';
|
||||
}
|
||||
|
||||
.kolidecon-import:before {
|
||||
content: '\f058';
|
||||
}
|
||||
|
||||
.kolidecon-pencil:before {
|
||||
content: '\f059';
|
||||
}
|
||||
|
||||
.kolidecon-add-plus:before {
|
||||
content: '\f05a';
|
||||
}
|
||||
|
||||
.kolidecon-x:before {
|
||||
content: '\f05b';
|
||||
}
|
||||
|
||||
.kolidecon-kill-kolide:before {
|
||||
content: '\f05c';
|
||||
}
|
||||
|
||||
.kolidecon-right-arrow:before {
|
||||
content: '\f05d';
|
||||
}
|
||||
|
||||
.kolidecon-camera:before {
|
||||
content: '\f05e';
|
||||
}
|
||||
|
||||
.kolidecon-plus-minus:before {
|
||||
content: '\f05f';
|
||||
}
|
||||
|
||||
.kolidecon-bold-plus:before {
|
||||
content: '\f060';
|
||||
}
|
||||
|
||||
.kolidecon-linux:before {
|
||||
content: '\f061';
|
||||
}
|
||||
|
||||
.kolidecon-clock2:before {
|
||||
content: '\f063';
|
||||
}
|
||||
|
||||
.kolidecon-trash:before {
|
||||
content: '\f064';
|
||||
}
|
||||
|
||||
.kolidecon-laptop-plus:before {
|
||||
content: '\f066';
|
||||
}
|
||||
|
||||
.kolidecon-wrench-hand:before {
|
||||
content: '\f067';
|
||||
}
|
||||
|
||||
.kolidecon-external-link:before {
|
||||
content: '\f068';
|
||||
}
|
||||
|
||||
.kolidecon-fullscreen:before {
|
||||
content: '\f069';
|
||||
}
|
||||
|
||||
.kolidecon-windowed:before {
|
||||
content: '\f06a';
|
||||
}
|
||||
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0
|
||||
}
|
||||
|
||||
.sr-only-focusable:active,
|
||||
.sr-only-focusable:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче