Full Screen Query Results (#1238)

This commit is contained in:
Kyle Knight 2017-02-24 17:01:43 -06:00 committed by Jason Meller
parent 6c6fb33915
commit b7fb83ce4b
26 changed files with 1145 additions and 670 deletions

View File

@ -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>

View File

@ -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');
});
});

View File

@ -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>&nbsp;of&nbsp;
<b>{totalHostsCount} Hosts</b>&nbsp;Returning&nbsp;
<b>{totalRowsCount} Records&nbsp;</b>
({hostsCount.failed} failed)
</span>
<ProgressBar
error={hostsCount.failed}
max={totalHostsCount}
success={hostsCount.successful}
/>
</div>
{queryIsRunning ? stopQueryBtn : runQueryBtn}
</div>
);
}
render () {
const {
error,
@ -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}

View File

@ -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');
});
});
});

View File

@ -2,17 +2,4 @@
&__wrapper {
padding: $base;
}
&__progress-details {
display: inline-block;
width: 378px;
}
&__query-btn-wrapper {
float: right;
button {
margin-left: 10px;
}
}
}

View File

@ -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>&nbsp;of&nbsp;
<b>{totalHostsCount} Hosts</b>&nbsp;Returning&nbsp;
<b>{totalRowsCount} Records&nbsp;</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;

View File

@ -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();
});
});
});

View File

@ -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;
}
}
}

View File

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

View File

@ -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>
);

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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;

View File

@ -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}
/>
);
}

View File

@ -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);
});
});
});

View File

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

View File

@ -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 } };
};

View File

@ -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;
}

View File

@ -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,
});
});

View File

@ -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});
}
}

View File

@ -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
}