diff --git a/frontend/components/buttons/EllipsisMenu/EllipsisMenu.jsx b/frontend/components/buttons/EllipsisMenu/EllipsisMenu.jsx
index 48a3fb74bf..d72d2a4ad8 100644
--- a/frontend/components/buttons/EllipsisMenu/EllipsisMenu.jsx
+++ b/frontend/components/buttons/EllipsisMenu/EllipsisMenu.jsx
@@ -102,8 +102,7 @@ export default ClickOutside(EllipsisMenu, {
return component.DOMNode;
},
onOutsideClick: (component) => {
- return (evt) => {
- evt.preventDefault();
+ return () => {
component.setState({ showChildren: false });
return false;
diff --git a/frontend/components/forms/fields/Dropdown/Dropdown.jsx b/frontend/components/forms/fields/Dropdown/Dropdown.jsx
index af0744a995..c668283ed1 100644
--- a/frontend/components/forms/fields/Dropdown/Dropdown.jsx
+++ b/frontend/components/forms/fields/Dropdown/Dropdown.jsx
@@ -2,7 +2,7 @@ import React, { Component, PropTypes } from 'react';
import Select from 'react-select';
import { noop } from 'lodash';
-import dropdownOptionInterface from '../../../../interfaces/dropdownOption';
+import dropdownOptionInterface from 'interfaces/dropdownOption';
class Dropdown extends Component {
static propTypes = {
diff --git a/frontend/components/forms/queries/QueryForm/QueryForm.jsx b/frontend/components/forms/queries/QueryForm/QueryForm.jsx
index 3e7da6ca1d..d546e5c0b1 100644
--- a/frontend/components/forms/queries/QueryForm/QueryForm.jsx
+++ b/frontend/components/forms/queries/QueryForm/QueryForm.jsx
@@ -2,6 +2,7 @@ import React, { Component, PropTypes } from 'react';
import { isEqual } from 'lodash';
import Button from 'components/buttons/Button';
+import Dropdown from 'components/forms/fields/Dropdown';
import helpers from 'components/forms/queries/QueryForm/helpers';
import InputField from 'components/forms/fields/InputField';
import queryInterface from 'interfaces/query';
@@ -11,11 +12,13 @@ const baseClass = 'query-form';
class QueryForm extends Component {
static propTypes = {
+ onCancel: PropTypes.func,
onRunQuery: PropTypes.func,
- onSaveAsNew: PropTypes.func,
- onSaveChanges: PropTypes.func,
+ onSave: PropTypes.func,
+ onUpdate: PropTypes.func,
query: queryInterface,
queryText: PropTypes.string.isRequired,
+ queryType: PropTypes.string,
};
static defaultProps = {
@@ -25,27 +28,34 @@ class QueryForm extends Component {
constructor (props) {
super(props);
- const { query: { description, name }, queryText } = this.props;
+ const {
+ query: { description, name },
+ queryText,
+ queryType,
+ } = this.props;
+ const errors = { description: null, name: null };
+ const formData = { description, name, query: queryText };
- this.state = {
- errors: {
- description: null,
- name: null,
- },
- formData: {
- description,
- name,
- query: queryText,
- },
- };
+ if (queryType === 'label') {
+ const { allPlatforms: platform } = helpers;
+
+ this.state = {
+ errors: { ...errors, platform: null },
+ formData: { ...formData, platform },
+ };
+ } else {
+ this.state = { errors, formData };
+ }
}
componentDidMount = () => {
const { query, queryText } = this.props;
const { description, name } = query;
+ const { formData } = this.state;
this.setState({
formData: {
+ ...formData,
description,
name,
query: queryText,
@@ -58,10 +68,13 @@ class QueryForm extends Component {
const { query: staleQuery, queryText: staleQueryText } = this.props;
if (!isEqual(query, staleQuery) || !isEqual(queryText, staleQueryText)) {
+ const { formData } = this.state;
+
this.setState({
formData: {
- description: query.description,
- name: query.name,
+ ...formData,
+ description: query.description || formData.description,
+ name: query.name || formData.name,
query: queryText,
},
});
@@ -70,11 +83,23 @@ class QueryForm extends Component {
return false;
}
+ onCancel = (evt) => {
+ evt.preventDefault();
+
+ const { onCancel: handleCancel } = this.props;
+
+ return handleCancel();
+ }
+
onFieldChange = (name) => {
return (value) => {
- const { formData } = this.state;
+ const { errors, formData } = this.state;
this.setState({
+ errors: {
+ ...errors,
+ [name]: null,
+ },
formData: {
...formData,
[name]: value,
@@ -85,29 +110,29 @@ class QueryForm extends Component {
};
}
- onSaveAsNew = (evt) => {
+ onSave = (evt) => {
evt.preventDefault();
const { formData } = this.state;
const { valid } = this;
- const { onSaveAsNew: handleSaveAsNew } = this.props;
+ const { onSave: handleSave } = this.props;
if (valid()) {
- handleSaveAsNew(formData);
+ handleSave(formData);
}
return false;
}
- onSaveChanges = (evt) => {
+ onUpdate = (evt) => {
evt.preventDefault();
const { formData } = this.state;
const { valid } = this;
- const { onSaveChanges: handleSaveChanges } = this.props;
+ const { onUpdate: handleUpdate } = this.props;
if (valid()) {
- handleSaveChanges(formData);
+ handleUpdate(formData);
}
return false;
@@ -115,6 +140,7 @@ class QueryForm extends Component {
valid = () => {
const { errors, formData: { name } } = this.state;
+ const { queryType } = this.props;
const namePresent = validatePresence(name);
@@ -122,7 +148,7 @@ class QueryForm extends Component {
this.setState({
errors: {
...errors,
- name: 'Query name must be present',
+ name: `${queryType === 'label' ? 'Label title' : 'Query title'} must be present`,
},
});
@@ -134,24 +160,94 @@ class QueryForm extends Component {
return true;
}
+ renderButtons = () => {
+ const { canSaveAsNew, canSaveChanges } = helpers;
+ const { formData } = this.state;
+ const { onRunQuery, query, queryType } = this.props;
+ const { onCancel, onSave, onUpdate } = this;
+
+ if (queryType === 'label') {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ renderPlatformDropdown = () => {
+ const { queryType } = this.props;
+
+ if (queryType !== 'label') {
+ return false;
+ }
+
+ const { formData: { platform } } = this.state;
+ const { onFieldChange } = this;
+ const { platformOptions } = helpers;
+
+ return (
+
+ );
+ }
+
render () {
const {
errors,
- formData,
formData: {
description,
name,
},
} = this.state;
- const { onFieldChange, onSaveAsNew, onSaveChanges } = this;
- const { onRunQuery, query } = this.props;
- const { canSaveAsNew, canSaveChanges } = helpers;
+ const { onFieldChange, renderPlatformDropdown, renderButtons } = this;
+ const { queryType } = this.props;
return (
);
}
diff --git a/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx b/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx
index 22a38149f6..74fa32b3d0 100644
--- a/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx
+++ b/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx
@@ -44,7 +44,7 @@ describe('QueryForm - component', () => {
it('validates the query name before saving changes', () => {
const onSaveChangesSpy = createSpy();
- const form = mount();
+ const form = mount();
const inputFields = form.find('InputField');
const nameInput = inputFields.find({ name: 'name' });
@@ -57,7 +57,7 @@ describe('QueryForm - component', () => {
expect(onSaveChangesSpy).toNotHaveBeenCalled();
expect(form.state()).toInclude({
errors: {
- name: 'Query name must be present',
+ name: 'Query title must be present',
description: null,
},
});
@@ -65,7 +65,7 @@ describe('QueryForm - component', () => {
it('calls the onSaveChanges prop when the form is valid', () => {
const onSaveChangesSpy = createSpy();
- const form = mount();
+ const form = mount();
const inputFields = form.find('InputField');
const nameInput = inputFields.find({ name: 'name' });
@@ -118,7 +118,7 @@ describe('QueryForm - component', () => {
it('calls the onSaveAsNew prop when "Save As New" is clicked and the form is valid', () => {
const onSaveAsNewSpy = createSpy();
- const form = mount();
+ const form = mount();
const inputFields = form.find('InputField');
const nameInput = inputFields.find({ name: 'name' });
const saveAsNewBtn = form.find('.query-form__save-as-new-btn');
@@ -136,7 +136,7 @@ describe('QueryForm - component', () => {
it('does not call the onSaveAsNew prop when "Save As New" is clicked and the form is not valid', () => {
const onSaveAsNewSpy = createSpy();
- const form = mount();
+ const form = mount();
const inputFields = form.find('InputField');
const nameInput = inputFields.find({ name: 'name' });
const saveAsNewBtn = form.find('.query-form__save-as-new-btn');
@@ -148,7 +148,7 @@ describe('QueryForm - component', () => {
expect(onSaveAsNewSpy).toNotHaveBeenCalled();
expect(form.state()).toInclude({
errors: {
- name: 'Query name must be present',
+ name: 'Query title must be present',
description: null,
},
});
diff --git a/frontend/components/forms/queries/QueryForm/helpers.js b/frontend/components/forms/queries/QueryForm/helpers.js
index 0214eb85b0..32e32a9855 100644
--- a/frontend/components/forms/queries/QueryForm/helpers.js
+++ b/frontend/components/forms/queries/QueryForm/helpers.js
@@ -30,4 +30,13 @@ const canSaveChanges = (formData, query) => {
return false;
};
-export default { canSaveAsNew, canSaveChanges };
+const allPlatforms = { label: 'All Platforms', value: '' };
+const platformOptions = [
+ allPlatforms,
+ { label: 'mac OS', value: 'darwin' },
+ { label: 'Windows', value: 'windows' },
+ { label: 'Ubuntu', value: 'ubuntu' },
+ { label: 'Centos', value: 'centos' },
+];
+
+export default { allPlatforms, canSaveAsNew, canSaveChanges, platformOptions };
diff --git a/frontend/pages/queries/QueryPage/helpers.js b/frontend/components/forms/validators/validate_query/index.js
similarity index 87%
rename from frontend/pages/queries/QueryPage/helpers.js
rename to frontend/components/forms/validators/validate_query/index.js
index 822e9487c5..350412f221 100644
--- a/frontend/pages/queries/QueryPage/helpers.js
+++ b/frontend/components/forms/validators/validate_query/index.js
@@ -9,6 +9,10 @@ const invalidQueryResponse = (message) => {
const validQueryResponse = { valid: true, error: null };
export const validateQuery = (queryText) => {
+ if (!queryText) {
+ return invalidQueryResponse('Query text must be present');
+ }
+
try {
const ast = sqliteParser(queryText);
const { statement } = ast;
@@ -26,4 +30,5 @@ export const validateQuery = (queryText) => {
}
};
-export default { validateQuery };
+export default validateQuery;
+
diff --git a/frontend/components/forms/validators/validate_query/validate_query.tests.js b/frontend/components/forms/validators/validate_query/validate_query.tests.js
new file mode 100644
index 0000000000..85bc292f00
--- /dev/null
+++ b/frontend/components/forms/validators/validate_query/validate_query.tests.js
@@ -0,0 +1,43 @@
+import expect from 'expect';
+
+import validateQuery from './index';
+
+const malformedQuery = 'this is not a thing';
+const validQuery = 'SELECT * FROM users';
+const createQuery = 'CREATE TABLE users (LastName varchar(255))';
+const insertQuery = 'INSERT INTO users (name) values ("Mike")';
+
+describe('validateQuery', () => {
+ it('rejects malformed queries', () => {
+ const { error, valid } = validateQuery(malformedQuery);
+
+ expect(valid).toEqual(false);
+ expect(error).toEqual('Syntax error found near WITH Clause (Statement)');
+ });
+
+ it('rejects blank queries', () => {
+ const { error, valid } = validateQuery();
+
+ expect(valid).toEqual(false);
+ expect(error).toEqual('Query text must be present');
+ });
+
+ it('rejects create queries', () => {
+ const { error, valid } = validateQuery(createQuery);
+ expect(valid).toEqual(false);
+ expect(error).toEqual('Cannot INSERT or CREATE in osquery queries');
+ });
+
+ it('rejects insert queries', () => {
+ const { error, valid } = validateQuery(insertQuery);
+ expect(valid).toEqual(false);
+ expect(error).toEqual('Cannot INSERT or CREATE in osquery queries');
+ });
+
+ it('accepts valid queries', () => {
+ const { error, valid } = validateQuery(validQuery);
+ expect(valid).toEqual(true);
+ expect(error).toNotExist();
+ });
+});
+
diff --git a/frontend/components/queries/QueryComposer/QueryComposer.jsx b/frontend/components/queries/QueryComposer/QueryComposer.jsx
index d5a831a98f..197d1e3aeb 100644
--- a/frontend/components/queries/QueryComposer/QueryComposer.jsx
+++ b/frontend/components/queries/QueryComposer/QueryComposer.jsx
@@ -17,23 +17,26 @@ class QueryComposer extends Component {
isLoadingTargets: PropTypes.bool,
moreInfoTarget: targetInterface,
onCloseTargetSelect: PropTypes.func,
+ onCancel: PropTypes.func,
onOsqueryTableSelect: PropTypes.func,
onRemoveMoreInfoTarget: PropTypes.func,
onRunQuery: PropTypes.func,
- onSaveQueryFormSubmit: PropTypes.func,
+ onSave: PropTypes.func,
onTargetSelect: PropTypes.func,
onTargetSelectInputChange: PropTypes.func,
onTargetSelectMoreInfo: PropTypes.func,
onTextEditorInputChange: PropTypes.func,
- onUpdateQuery: PropTypes.func,
+ onUpdate: PropTypes.func,
query: queryInterface,
+ queryType: PropTypes.string,
selectedTargets: PropTypes.arrayOf(targetInterface),
selectedTargetsCount: PropTypes.number,
targets: PropTypes.arrayOf(targetInterface),
- textEditorText: PropTypes.string,
+ queryText: PropTypes.string,
};
static defaultProps = {
+ queryType: 'query',
selectedTargetsCount: 0,
};
@@ -76,27 +79,31 @@ class QueryComposer extends Component {
return false;
}
- renderQueryComposerActions = () => {
+ renderForm = () => {
const {
+ onCancel,
onRunQuery,
- onSaveQueryFormSubmit,
- onUpdateQuery,
+ onSave,
+ onUpdate,
query,
- textEditorText,
+ queryText,
+ queryType,
} = this.props;
return (
);
}
- render () {
+ renderTargetsInput = () => {
const {
isLoadingTargets,
moreInfoTarget,
@@ -105,21 +112,40 @@ class QueryComposer extends Component {
onTargetSelect,
onTargetSelectInputChange,
onTargetSelectMoreInfo,
- onTextEditorInputChange,
+ queryType,
selectedTargets,
selectedTargetsCount,
targets,
- textEditorText,
} = this.props;
- const {
- onLoad,
- renderQueryComposerActions,
- } = this;
- const menuRenderer = SelectTargets.Menu(
- onTargetSelectMoreInfo,
- onRemoveMoreInfoTarget,
- moreInfoTarget,
+
+ if (queryType === 'label') {
+ return false;
+ }
+
+ const menuRenderer = SelectTargets.Menu(onTargetSelectMoreInfo, onRemoveMoreInfoTarget, moreInfoTarget);
+
+ return (
+
+
+ Select Targets
+ {selectedTargetsCount} unique hosts
+
+
+
);
+ }
+
+ render () {
+ const { onTextEditorInputChange, queryText } = this.props;
+ const { onLoad, renderForm, renderTargetsInput } = this;
return (
@@ -138,26 +164,12 @@ class QueryComposer extends Component {
showGutter
showPrintMargin={false}
theme="kolide"
- value={textEditorText}
+ value={queryText}
width="100%"
/>
-
-
- Select Targets
- {selectedTargetsCount} unique hosts
-
-
-
- {renderQueryComposerActions()}
+ {renderTargetsInput()}
+ {renderForm()}
);
}
diff --git a/frontend/components/queries/QueryComposer/QueryComposer.tests.jsx b/frontend/components/queries/QueryComposer/QueryComposer.tests.jsx
index af727784c0..b83db81891 100644
--- a/frontend/components/queries/QueryComposer/QueryComposer.tests.jsx
+++ b/frontend/components/queries/QueryComposer/QueryComposer.tests.jsx
@@ -18,7 +18,7 @@ describe('QueryComposer - component', () => {
onOsqueryTableSelect={noop}
onTextEditorInputChange={noop}
selectedTargets={[]}
- textEditorText="Hello world"
+ queryText="Hello world"
/>
);
@@ -38,7 +38,7 @@ describe('QueryComposer - component', () => {
onTextEditorInputChange={noop}
query={query}
selectedTargets={[]}
- textEditorText="Hello world"
+ queryText="Hello world"
/>
);
@@ -54,9 +54,9 @@ describe('QueryComposer - component', () => {
const selectedTargets = [{ name: 'my target' }];
const component = mount(
);
@@ -81,7 +81,7 @@ describe('QueryComposer - component', () => {
);
component.find('.query-form__run-query-btn').simulate('click');
@@ -98,9 +98,9 @@ describe('QueryComposer - component', () => {
};
const component = mount(
);
const form = component.find('QueryForm');
@@ -121,7 +121,7 @@ describe('QueryComposer - component', () => {
);
const selectTargetsInput = component.find('.Select-input input');
diff --git a/frontend/components/side_panels/HostSidePanel/HostSidePanel.jsx b/frontend/components/side_panels/HostSidePanel/HostSidePanel.jsx
new file mode 100644
index 0000000000..7e5f74cef6
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/HostSidePanel.jsx
@@ -0,0 +1,94 @@
+import React, { Component, PropTypes } from 'react';
+import { filter } from 'lodash';
+
+import InputField from 'components/forms/fields/InputField';
+import labelInterface from 'interfaces/label';
+import PanelGroup from 'components/side_panels/HostSidePanel/PanelGroup';
+import SecondarySidePanelContainer from 'components/side_panels/SecondarySidePanelContainer';
+
+const baseClass = 'host-side-panel';
+
+class HostSidePanel extends Component {
+ static propTypes = {
+ labels: PropTypes.arrayOf(labelInterface),
+ onAddLabelClick: PropTypes.func,
+ onLabelClick: PropTypes.func,
+ selectedLabel: labelInterface,
+ };
+
+ constructor (props) {
+ super(props);
+
+ this.state = { labelFilter: '' };
+ }
+
+ onFilterLabels = (labelFilter) => {
+ const lowerLabelFilter = labelFilter.toLowerCase();
+ this.setState({ labelFilter: lowerLabelFilter });
+
+ return false;
+ }
+
+ render () {
+ const { labels, onAddLabelClick, onLabelClick, selectedLabel } = this.props;
+ const { labelFilter } = this.state;
+ const { onFilterLabels } = this;
+ const allHostLabels = filter(labels, { type: 'all' });
+ const hostStatusLabels = filter(labels, { type: 'status' });
+ const hostPlatformLabels = filter(labels, { type: 'platform' });
+ const customLabels = filter(labels, (label) => {
+ const lowerDisplayText = label.display_text.toLowerCase();
+
+ return label.type === 'custom' &&
+ lowerDisplayText.match(labelFilter);
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ LABELS
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default HostSidePanel;
diff --git a/frontend/components/side_panels/HostSidePanel/PanelGroup/PanelGroup.jsx b/frontend/components/side_panels/HostSidePanel/PanelGroup/PanelGroup.jsx
new file mode 100644
index 0000000000..6c1c7e804d
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/PanelGroup/PanelGroup.jsx
@@ -0,0 +1,49 @@
+import React, { Component, PropTypes } from 'react';
+import { isEqual, noop } from 'lodash';
+
+import labelInterface from 'interfaces/label';
+import PanelGroupItem from '../PanelGroupItem';
+
+class PanelGroup extends Component {
+ static propTypes = {
+ groupItems: PropTypes.arrayOf(labelInterface),
+ onLabelClick: PropTypes.func,
+ selectedLabel: labelInterface,
+ };
+
+ static defaultProps = {
+ onLabelClick: noop,
+ };
+
+ renderGroupItem = (item) => {
+ const {
+ onLabelClick,
+ selectedLabel,
+ } = this.props;
+ const selected = isEqual(selectedLabel, item);
+
+ return (
+
+ );
+ }
+
+ render () {
+ const { groupItems } = this.props;
+ const { renderGroupItem } = this;
+
+ return (
+
+ {groupItems.map((item) => {
+ return renderGroupItem(item);
+ })}
+
+ );
+ }
+}
+
+export default PanelGroup;
diff --git a/frontend/components/side_panels/HostSidePanel/PanelGroup/PanelGroup.tests.jsx b/frontend/components/side_panels/HostSidePanel/PanelGroup/PanelGroup.tests.jsx
new file mode 100644
index 0000000000..033520c3bb
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/PanelGroup/PanelGroup.tests.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import expect from 'expect';
+import { mount } from 'enzyme';
+
+import PanelGroup from './PanelGroup';
+
+describe('PanelGroup - component', () => {
+ const validPanelGroupItems = [
+ { type: 'all', title: 'All Hosts', hosts_count: 20 },
+ { type: 'platform', title: 'MAC OS', hosts_count: 10 },
+ { type: 'status', title: 'ONLINE', hosts_count: 10 },
+ ];
+
+ const component = mount(
+
+ );
+
+ it('renders a PanelGroupItem for each group item', () => {
+ const panelGroupItems = component.find('PanelGroupItem');
+
+ expect(panelGroupItems.length).toEqual(3);
+ });
+});
+
diff --git a/frontend/components/side_panels/HostSidePanel/PanelGroup/index.js b/frontend/components/side_panels/HostSidePanel/PanelGroup/index.js
new file mode 100644
index 0000000000..e1def54b52
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/PanelGroup/index.js
@@ -0,0 +1 @@
+export default from './PanelGroup';
diff --git a/frontend/components/side_panels/HostSidePanel/PanelGroupItem/PanelGroupItem.jsx b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/PanelGroupItem.jsx
new file mode 100644
index 0000000000..3b3bf736ba
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/PanelGroupItem.jsx
@@ -0,0 +1,39 @@
+import React, { Component, PropTypes } from 'react';
+import classnames from 'classnames';
+
+import { iconClassForLabel } from './helpers';
+
+const baseClass = 'panel-group-item';
+
+class PanelGroupItem extends Component {
+ static propTypes = {
+ item: PropTypes.shape({
+ hosts_count: PropTypes.number,
+ title: PropTypes.string,
+ type: PropTypes.string,
+ }).isRequired,
+ onLabelClick: PropTypes.func,
+ isSelected: PropTypes.bool,
+ };
+
+ render () {
+ const { item, onLabelClick, isSelected } = this.props;
+ const {
+ count,
+ display_text: displayText,
+ } = item;
+ const wrapperClassName = classnames(baseClass, `${baseClass}__wrapper`, {
+ [`${baseClass}__wrapper--is-selected`]: isSelected,
+ });
+
+ return (
+
+ );
+ }
+}
+
+export default PanelGroupItem;
diff --git a/frontend/components/side_panels/HostSidePanel/PanelGroupItem/PanelGroupItem.tests.jsx b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/PanelGroupItem.tests.jsx
new file mode 100644
index 0000000000..b820cfbbe3
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/PanelGroupItem.tests.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import expect from 'expect';
+import { mount } from 'enzyme';
+
+import PanelGroupItem from './PanelGroupItem';
+
+describe('PanelGroupItem - component', () => {
+ const validPanelGroupItem = {
+ count: 20,
+ display_text: 'All Hosts',
+ type: 'all',
+ };
+
+ const component = mount(
+
+ );
+
+ it('renders the icon', () => {
+ const icon = component.find('i.kolidecon-hosts');
+
+ expect(icon.length).toEqual(1);
+ });
+
+ it('renders the item text', () => {
+ expect(component.text()).toContain(validPanelGroupItem.display_text);
+ });
+
+ it('renders the item count', () => {
+ expect(component.text()).toContain(validPanelGroupItem.count);
+ });
+});
diff --git a/frontend/components/side_panels/HostSidePanel/PanelGroupItem/_styles.scss b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/_styles.scss
new file mode 100644
index 0000000000..f301f15584
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/_styles.scss
@@ -0,0 +1,12 @@
+.panel-group-item {
+ &__wrapper {
+ color: $text-medium;
+ display: block;
+ width: 100%;
+
+ &--is-selected {
+ background-color: $brand;
+ color: $white;
+ };
+ }
+}
diff --git a/frontend/components/side_panels/HostSidePanel/PanelGroupItem/helpers.js b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/helpers.js
new file mode 100644
index 0000000000..0a072cd72a
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/helpers.js
@@ -0,0 +1,18 @@
+export const iconClassForLabel = (label) => {
+ const lowerType = label.type && label.type.toLowerCase();
+ const lowerDisplayText = label.display_text && label.display_text.toLowerCase();
+
+ if (lowerType === 'all') return 'kolidecon-hosts';
+
+ switch (lowerDisplayText) {
+ case 'offline': return 'kolidecon-hosts';
+ case 'online': return 'kolidecon-hosts';
+ case 'mac os': return 'kolidecon-apple';
+ case 'centos': return 'kolidecon-centos';
+ case 'ubuntu': return 'kolidecon-ubuntu';
+ case 'windows': return 'kolidecon-windows';
+ default: return 'kolidecon-label';
+ }
+};
+
+export default { iconClassForLabel };
diff --git a/frontend/components/side_panels/HostSidePanel/PanelGroupItem/index.js b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/index.js
new file mode 100644
index 0000000000..dec167a1bf
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/PanelGroupItem/index.js
@@ -0,0 +1 @@
+export default from './PanelGroupItem';
diff --git a/frontend/components/side_panels/HostSidePanel/_styles.scss b/frontend/components/side_panels/HostSidePanel/_styles.scss
new file mode 100644
index 0000000000..a560948c9d
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/_styles.scss
@@ -0,0 +1,26 @@
+.host-side-panel {
+ &__add-label-btn {
+ color: $success;
+ }
+
+ &__wrapper {
+ color: $text-medium;
+ padding: 0;
+ }
+
+ &__hr {
+ color: $text-medium;
+ margin: $pad-base $pad-auto;
+ width: 80%;
+ }
+
+ &__panel-group-item {
+ background-color: $white;
+ color: $text-medium;
+ padding: $pad-half $pad-large;
+
+ .title {
+ padding-left: 20px;
+ }
+ }
+}
diff --git a/frontend/components/side_panels/HostSidePanel/index.js b/frontend/components/side_panels/HostSidePanel/index.js
new file mode 100644
index 0000000000..255ebb95e5
--- /dev/null
+++ b/frontend/components/side_panels/HostSidePanel/index.js
@@ -0,0 +1 @@
+export default from './HostSidePanel';
diff --git a/frontend/components/side_panels/SecondarySidePanelContainer/SecondarySidePanelContainer.jsx b/frontend/components/side_panels/SecondarySidePanelContainer/SecondarySidePanelContainer.jsx
index eec272a57a..8c1977ac35 100644
--- a/frontend/components/side_panels/SecondarySidePanelContainer/SecondarySidePanelContainer.jsx
+++ b/frontend/components/side_panels/SecondarySidePanelContainer/SecondarySidePanelContainer.jsx
@@ -3,13 +3,14 @@ import React, { Component, PropTypes } from 'react';
class SecondarySidePanelContainer extends Component {
static propTypes = {
children: PropTypes.node,
+ className: PropTypes.string,
};
render () {
- const { children } = this.props;
+ const { children, className } = this.props;
return (
-
+
{children}
);
diff --git a/frontend/interfaces/label.js b/frontend/interfaces/label.js
new file mode 100644
index 0000000000..4493118943
--- /dev/null
+++ b/frontend/interfaces/label.js
@@ -0,0 +1,8 @@
+import { PropTypes } from 'react';
+
+export default PropTypes.shape({
+ hosts_count: PropTypes.number,
+ id: PropTypes.number,
+ title: PropTypes.string,
+ type: PropTypes.string,
+});
diff --git a/frontend/kolide/endpoints.js b/frontend/kolide/endpoints.js
index ecaa452eb4..300e379cc6 100644
--- a/frontend/kolide/endpoints.js
+++ b/frontend/kolide/endpoints.js
@@ -3,6 +3,7 @@ export default {
FORGOT_PASSWORD: '/v1/kolide/forgot_password',
HOSTS: '/v1/kolide/hosts',
INVITES: '/v1/kolide/invites',
+ LABELS: '/v1/kolide/labels',
LABEL_HOSTS: (id) => {
return `/v1/kolide/labels/${id}/hosts`;
},
diff --git a/frontend/kolide/index.js b/frontend/kolide/index.js
index 6d928460c6..32978518e2 100644
--- a/frontend/kolide/index.js
+++ b/frontend/kolide/index.js
@@ -3,6 +3,18 @@ import endpoints from './endpoints';
import { appendTargetTypeToTargets } from '../redux/nodes/entities/targets/helpers';
class Kolide extends Base {
+ createLabel = ({ description, name, query }) => {
+ const { LABELS } = endpoints;
+
+ return this.authenticatedPost(this.endpoint(LABELS), JSON.stringify({ description, name, query }))
+ .then((response) => {
+ return {
+ ...response.label,
+ type: 'custom',
+ };
+ });
+ }
+
createQuery = ({ description, name, query }) => {
const { QUERIES } = endpoints;
@@ -106,6 +118,28 @@ class Kolide extends Base {
});
}
+ getLabels = () => {
+ const { LABELS } = endpoints;
+
+ return this.authenticatedGet(this.endpoint(LABELS))
+ .then((response) => {
+ const labels = response.labels.map((label) => {
+ return { ...label, type: 'custom' };
+ });
+ const stubbedLabels = [
+ { id: 100, display_text: 'All Hosts', type: 'all', count: 22 },
+ { id: 40, display_text: 'OFFLINE', type: 'status', count: 2 },
+ { id: 50, display_text: 'ONLINE', type: 'status', count: 20 },
+ { id: 60, display_text: 'MAC OS', type: 'platform', count: 1 },
+ { id: 70, display_text: 'CENTOS', type: 'platform', count: 10 },
+ { id: 80, display_text: 'UBUNTU', type: 'platform', count: 10 },
+ { id: 110, display_text: 'WINDOWS', type: 'platform', count: 1 },
+ ];
+
+ return labels.concat(stubbedLabels);
+ });
+ }
+
getUsers = () => {
const { USERS } = endpoints;
diff --git a/frontend/kolide/index.tests.js b/frontend/kolide/index.tests.js
index 9cba67874c..bc58a053e7 100644
--- a/frontend/kolide/index.tests.js
+++ b/frontend/kolide/index.tests.js
@@ -7,6 +7,7 @@ import mocks from '../test/mocks';
const {
invalidForgotPasswordRequest,
invalidResetPasswordRequest,
+ validCreateLabelRequest,
validCreateQueryRequest,
validForgotPasswordRequest,
validGetConfigRequest,
@@ -35,6 +36,26 @@ describe('Kolide - API client', () => {
});
});
+ describe('#createLabel', () => {
+ it('calls the appropriate endpoint with the correct parameters', (done) => {
+ const bearerToken = 'valid-bearer-token';
+ const description = 'label description';
+ const name = 'label name';
+ const query = 'SELECT * FROM users';
+ const labelParams = { description, name, query };
+ const request = validCreateLabelRequest(bearerToken, labelParams);
+
+ Kolide.setBearerToken(bearerToken);
+ Kolide.createLabel(labelParams)
+ .then((labelResponse) => {
+ expect(request.isDone()).toEqual(true);
+ expect(labelResponse).toEqual({ ...labelParams, type: 'custom' });
+ done();
+ })
+ .catch(done);
+ });
+ });
+
describe('#createQuery', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';
diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
index d6e71cb67b..f6db34a7c1 100644
--- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
+++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx
@@ -1,24 +1,99 @@
import React, { Component, PropTypes } from 'react';
+import AceEditor from 'react-ace';
import { connect } from 'react-redux';
+import { filter } from 'lodash';
+import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
-import entityGetter from '../../../redux/utilities/entityGetter';
-import hostActions from '../../../redux/nodes/entities/hosts/actions';
-import HostDetails from '../../../components/hosts/HostDetails';
-import hostInterface from '../../../interfaces/host';
+import entityGetter from 'redux/utilities/entityGetter';
+import hostActions from 'redux/nodes/entities/hosts/actions';
+import labelActions from 'redux/nodes/entities/labels/actions';
+import labelInterface from 'interfaces/label';
+import HostDetails from 'components/hosts/HostDetails';
+import hostInterface from 'interfaces/host';
+import HostSidePanel from 'components/side_panels/HostSidePanel';
+import osqueryTableInterface from 'interfaces/osquery_table';
+import QueryComposer from 'components/queries/QueryComposer';
+import QuerySidePanel from 'components/side_panels/QuerySidePanel';
+import { renderFlash } from 'redux/nodes/notifications/actions';
+import { selectOsqueryTable } from 'redux/nodes/components/QueryPages/actions';
+import { setSelectedLabel } from 'redux/nodes/components/ManageHostsPage/actions';
+import { showRightSidePanel, removeRightSidePanel } from 'redux/nodes/app/actions';
+import validateQuery from 'components/forms/validators/validate_query';
-class ManageHostsPage extends Component {
+export class ManageHostsPage extends Component {
static propTypes = {
dispatch: PropTypes.func,
hosts: PropTypes.arrayOf(hostInterface),
+ labels: PropTypes.arrayOf(labelInterface),
+ selectedLabel: labelInterface,
+ selectedOsqueryTable: osqueryTableInterface,
};
+ constructor (props) {
+ super(props);
+
+ this.state = {
+ isAddLabel: false,
+ labelQueryText: '',
+ };
+ }
+
componentWillMount () {
- const { dispatch, hosts } = this.props;
+ const {
+ dispatch,
+ hosts,
+ labels,
+ selectedLabel,
+ } = this.props;
+ const allHostLabel = filter(labels, { type: 'all' })[0];
+
+ dispatch(showRightSidePanel);
if (!hosts.length) {
dispatch(hostActions.loadAll());
}
+ if (!labels.length) {
+ dispatch(labelActions.loadAll());
+ }
+
+ if (!selectedLabel) {
+ dispatch(setSelectedLabel(allHostLabel));
+ }
+
+ return false;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ const { dispatch, labels, selectedLabel } = nextProps;
+ const allHostLabel = filter(labels, { type: 'all' })[0];
+
+ if (!selectedLabel && !!allHostLabel) {
+ dispatch(setSelectedLabel(allHostLabel));
+ }
+
+ return false;
+ }
+
+ componentWillUnmount () {
+ const { dispatch } = this.props;
+
+ dispatch(removeRightSidePanel);
+ }
+
+ onCancelAddLabel = () => {
+ this.setState({ isAddLabel: false });
+
+ return false;
+ }
+
+ onAddLabelClick = (evt) => {
+ evt.preventDefault();
+
+ this.setState({
+ isAddLabel: true,
+ });
+
return false;
}
@@ -33,9 +108,109 @@ class ManageHostsPage extends Component {
};
}
- renderHosts = () => {
+ onLabelClick = (selectedLabel) => {
+ return (evt) => {
+ evt.preventDefault();
+
+ const { dispatch } = this.props;
+
+ dispatch(setSelectedLabel(selectedLabel));
+
+ return false;
+ };
+ }
+
+ onOsqueryTableSelect = (tableName) => {
+ const { dispatch } = this.props;
+
+ dispatch(selectOsqueryTable(tableName));
+
+ return false;
+ }
+
+ onSaveAddLabel = (formData) => {
+ const { dispatch } = this.props;
+ const { labelQueryText } = this.state;
+
+ const { error } = validateQuery(labelQueryText);
+
+ if (error) {
+ dispatch(renderFlash('error', error));
+
+ return false;
+ }
+
+ return dispatch(labelActions.create(formData))
+ .then(() => {
+ this.setState({ isAddLabel: false });
+
+ return false;
+ });
+ }
+
+ onTextEditorInputChange = (labelQueryText) => {
+ this.setState({ labelQueryText });
+
+ return false;
+ }
+
+ renderHeader = () => {
+ const { selectedLabel } = this.props;
+ const { isAddLabel } = this.state;
+
+ if (!selectedLabel || isAddLabel) {
+ return false;
+ }
+
+ const { count, description, display_text: displayText, query } = selectedLabel;
+
+ return (
+
+
+
{displayText}
+
+
Description
+
{description}
+
{count} Hosts Total
+
+ );
+ }
+
+ renderBody = () => {
const { hosts } = this.props;
- const { onHostDetailActionClick } = this;
+ const { isAddLabel, labelQueryText } = this.state;
+ const {
+ onCancelAddLabel,
+ onHostDetailActionClick,
+ onSaveAddLabel,
+ onTextEditorInputChange,
+ } = this;
+
+ if (isAddLabel) {
+ return (
+
+ );
+ }
return hosts.map((host) => {
return (
@@ -49,12 +224,55 @@ class ManageHostsPage extends Component {
});
}
+ renderSidePanel = () => {
+ let SidePanel;
+ const { isAddLabel } = this.state;
+ const {
+ labels,
+ selectedLabel,
+ selectedOsqueryTable,
+ } = this.props;
+ const { onAddLabelClick, onLabelClick, onOsqueryTableSelect } = this;
+
+ if (isAddLabel) {
+ SidePanel = (
+
+ );
+ } else {
+ SidePanel = (
+
+ );
+ }
+
+ return (
+
+ {SidePanel}
+
+ );
+ }
+
render () {
- const { renderHosts } = this;
+ const { renderBody, renderHeader, renderSidePanel } = this;
return (
- {renderHosts()}
+ {renderHeader()}
+ {renderBody()}
+ {renderSidePanel()}
);
}
@@ -62,8 +280,16 @@ class ManageHostsPage extends Component {
const mapStateToProps = (state) => {
const { entities: hosts } = entityGetter(state).get('hosts');
+ const { entities: labels } = entityGetter(state).get('labels');
+ const { selectedLabel } = state.components.ManageHostsPage;
+ const { selectedOsqueryTable } = state.components.QueryPages;
- return { hosts };
+ return {
+ hosts,
+ labels,
+ selectedLabel,
+ selectedOsqueryTable,
+ };
};
export default connect(mapStateToProps)(ManageHostsPage);
diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.jsx
new file mode 100644
index 0000000000..59647d95fb
--- /dev/null
+++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import expect from 'expect';
+import { mount } from 'enzyme';
+import { noop } from 'lodash';
+
+import { ManageHostsPage } from 'pages/hosts/ManageHostsPage/ManageHostsPage';
+import { createAceSpy, stubbedOsqueryTable } from 'test/helpers';
+
+describe('ManageHostsPage - component', () => {
+ const props = {
+ allHostLabels: [],
+ dispatch: noop,
+ hosts: [],
+ hostPlatformLabels: [],
+ hostStatusLabels: [],
+ labels: [],
+ selectedOsqueryTable: stubbedOsqueryTable,
+ };
+
+ beforeEach(() => {
+ createAceSpy();
+ });
+
+ it('renders a HostSidePanel when not adding a new label', () => {
+ const page = mount(
);
+
+ expect(page.find('HostSidePanel').length).toEqual(1);
+ });
+
+ it('renders a QuerySidePanel when adding a new label', () => {
+ const page = mount(
);
+
+ page.setState({ isAddLabel: true });
+
+ expect(page.find('QuerySidePanel').length).toEqual(1);
+ });
+});
diff --git a/frontend/pages/hosts/ManageHostsPage/_styles.scss b/frontend/pages/hosts/ManageHostsPage/_styles.scss
index 4eceb474b6..574d9d0e71 100644
--- a/frontend/pages/hosts/ManageHostsPage/_styles.scss
+++ b/frontend/pages/hosts/ManageHostsPage/_styles.scss
@@ -2,3 +2,23 @@
background-color: $white;
padding: $pad-base;
}
+
+.hosts-page-side-panel {
+ &-enter {
+ right: -300px;
+
+ &.hosts-page-side-panel-enter-active {
+ right: 0;
+ transition: right 300ms ease-in;
+ }
+ }
+
+ &-leave {
+ right: 0;
+
+ &.hosts-page-side-panel-leave-active {
+ right: -300px;
+ transition: right 200ms ease-in;
+ }
+ }
+}
diff --git a/frontend/pages/queries/QueryPage/QueryPage.jsx b/frontend/pages/queries/QueryPage/QueryPage.jsx
index f8fd2c6ed7..ff83867da5 100644
--- a/frontend/pages/queries/QueryPage/QueryPage.jsx
+++ b/frontend/pages/queries/QueryPage/QueryPage.jsx
@@ -15,7 +15,7 @@ import { removeRightSidePanel, showRightSidePanel } from 'redux/nodes/app/action
import { renderFlash } from 'redux/nodes/notifications/actions';
import { selectOsqueryTable, setQueryText, setSelectedTargets, setSelectedTargetsQuery } from 'redux/nodes/components/QueryPages/actions';
import targetInterface from 'interfaces/target';
-import { validateQuery } from 'pages/queries/QueryPage/helpers';
+import validateQuery from 'components/forms/validators/validate_query';
class QueryPage extends Component {
static propTypes = {
@@ -265,18 +265,18 @@ class QueryPage extends Component {
onOsqueryTableSelect={onOsqueryTableSelect}
onRemoveMoreInfoTarget={onRemoveMoreInfoTarget}
onRunQuery={onRunQuery}
- onSaveQueryFormSubmit={onSaveQueryFormSubmit}
+ onSave={onSaveQueryFormSubmit}
onTargetSelect={onTargetSelect}
onTargetSelectInputChange={fetchTargets}
onTargetSelectMoreInfo={onTargetSelectMoreInfo}
onTextEditorInputChange={onTextEditorInputChange}
- onUpdateQuery={onUpdateQuery}
+ onUpdate={onUpdateQuery}
query={query}
selectedTargets={selectedTargets}
selectedTargetsCount={selectedTargetsCount}
selectedOsqueryTable={selectedOsqueryTable}
targets={targets}
- textEditorText={queryText}
+ queryText={queryText}
/>
{
- describe('#validateQuery', () => {
- const { validateQuery } = helpers;
-
- it('rejects malformed queries', () => {
- const { error, valid } = validateQuery(malformedQuery);
-
- expect(valid).toEqual(false);
- expect(error).toEqual('Syntax error found near WITH Clause (Statement)');
- });
-
- it('rejects create queries', () => {
- const { error, valid } = validateQuery(createQuery);
- expect(valid).toEqual(false);
- expect(error).toEqual('Cannot INSERT or CREATE in osquery queries');
- });
-
- it('rejects insert queries', () => {
- const { error, valid } = validateQuery(insertQuery);
- expect(valid).toEqual(false);
- expect(error).toEqual('Cannot INSERT or CREATE in osquery queries');
- });
-
- it('accepts valid queries', () => {
- const { error, valid } = validateQuery(validQuery);
- expect(valid).toEqual(true);
- expect(error).toNotExist();
- });
- });
-});
diff --git a/frontend/redux/nodes/components/ManageHostsPage/actions.js b/frontend/redux/nodes/components/ManageHostsPage/actions.js
new file mode 100644
index 0000000000..d507b0f57b
--- /dev/null
+++ b/frontend/redux/nodes/components/ManageHostsPage/actions.js
@@ -0,0 +1,13 @@
+export const SET_SELECTED_LABEL = 'SET_SELECTED_LABEL';
+export const setSelectedLabel = (selectedLabel) => {
+ return {
+ type: SET_SELECTED_LABEL,
+ payload: {
+ selectedLabel,
+ },
+ };
+};
+
+export default {
+ setSelectedLabel,
+};
diff --git a/frontend/redux/nodes/components/ManageHostsPage/reducer.js b/frontend/redux/nodes/components/ManageHostsPage/reducer.js
new file mode 100644
index 0000000000..b98d0ede08
--- /dev/null
+++ b/frontend/redux/nodes/components/ManageHostsPage/reducer.js
@@ -0,0 +1,17 @@
+import { SET_SELECTED_LABEL } from './actions';
+
+const initialState = {
+ selectedLabel: null,
+};
+
+export default (state = initialState, { type, payload }) => {
+ switch (type) {
+ case SET_SELECTED_LABEL:
+ return {
+ ...state,
+ selectedLabel: payload.selectedLabel,
+ };
+ default:
+ return state;
+ }
+};
diff --git a/frontend/redux/nodes/components/reducer.js b/frontend/redux/nodes/components/reducer.js
index f2b53e2c07..a33b0c3665 100644
--- a/frontend/redux/nodes/components/reducer.js
+++ b/frontend/redux/nodes/components/reducer.js
@@ -1,11 +1,13 @@
import { combineReducers } from 'redux';
import ForgotPasswordPage from './ForgotPasswordPage/reducer';
+import ManageHostsPage from './ManageHostsPage/reducer';
import QueryPages from './QueryPages/reducer';
import ResetPasswordPage from './ResetPasswordPage/reducer';
export default combineReducers({
ForgotPasswordPage,
+ ManageHostsPage,
QueryPages,
ResetPasswordPage,
});
diff --git a/frontend/redux/nodes/entities/base/schemas.js b/frontend/redux/nodes/entities/base/schemas.js
index 399bd0f4d1..5165a7c82d 100644
--- a/frontend/redux/nodes/entities/base/schemas.js
+++ b/frontend/redux/nodes/entities/base/schemas.js
@@ -2,6 +2,7 @@ import { Schema } from 'normalizr';
const hostsSchema = new Schema('hosts');
const invitesSchema = new Schema('invites');
+const labelsSchema = new Schema('labels');
const queriesSchema = new Schema('queries');
const targetsSchema = new Schema('targets');
const usersSchema = new Schema('users');
@@ -9,6 +10,7 @@ const usersSchema = new Schema('users');
export default {
HOSTS: hostsSchema,
INVITES: invitesSchema,
+ LABELS: labelsSchema,
QUERIES: queriesSchema,
TARGETS: targetsSchema,
USERS: usersSchema,
diff --git a/frontend/redux/nodes/entities/labels/actions.js b/frontend/redux/nodes/entities/labels/actions.js
new file mode 100644
index 0000000000..b95c41eb89
--- /dev/null
+++ b/frontend/redux/nodes/entities/labels/actions.js
@@ -0,0 +1,3 @@
+import config from './config';
+
+export default config.actions;
diff --git a/frontend/redux/nodes/entities/labels/config.js b/frontend/redux/nodes/entities/labels/config.js
new file mode 100644
index 0000000000..b8205cd6cf
--- /dev/null
+++ b/frontend/redux/nodes/entities/labels/config.js
@@ -0,0 +1,12 @@
+import Kolide from '../../../../kolide';
+import reduxConfig from '../base/reduxConfig';
+import schemas from '../base/schemas';
+
+const { LABELS: schema } = schemas;
+
+export default reduxConfig({
+ createFunc: Kolide.createLabel,
+ entityName: 'labels',
+ loadAllFunc: Kolide.getLabels,
+ schema,
+});
diff --git a/frontend/redux/nodes/entities/labels/reducer.js b/frontend/redux/nodes/entities/labels/reducer.js
new file mode 100644
index 0000000000..93a1bd364b
--- /dev/null
+++ b/frontend/redux/nodes/entities/labels/reducer.js
@@ -0,0 +1,3 @@
+import config from './config';
+
+export default config.reducer;
diff --git a/frontend/redux/nodes/entities/reducer.js b/frontend/redux/nodes/entities/reducer.js
index 8bb84a3211..9da0ab2959 100644
--- a/frontend/redux/nodes/entities/reducer.js
+++ b/frontend/redux/nodes/entities/reducer.js
@@ -2,12 +2,14 @@ import { combineReducers } from 'redux';
import hosts from './hosts/reducer';
import invites from './invites/reducer';
+import labels from './labels/reducer';
import queries from './queries/reducer';
import users from './users/reducer';
export default combineReducers({
hosts,
invites,
+ labels,
queries,
users,
});
diff --git a/frontend/test/helpers.jsx b/frontend/test/helpers.jsx
index 6df943ac8e..5a5b469b92 100644
--- a/frontend/test/helpers.jsx
+++ b/frontend/test/helpers.jsx
@@ -59,10 +59,37 @@ export const createAceSpy = () => {
});
};
+export const stubbedOsqueryTable = {
+ attributes: {},
+ blacklisted: false,
+ columns: [
+ { description: 'User ID', name: 'uid', options: { index: true }, type: 'BIGINT_TYPE' },
+ { description: 'Group ID (unsigned)', name: 'gid', options: {}, type: 'BIGINT_TYPE' },
+ { description: 'User ID as int64 signed (Apple)', name: 'uid_signed', options: {}, type: 'BIGINT_TYPE' },
+ { description: 'Default group ID as int64 signed (Apple)', name: 'gid_signed', options: {}, type: 'BIGINT_TYPE' },
+ { description: 'Username', name: 'username', options: {}, type: 'TEXT_TYPE' },
+ { description: 'Optional user description', name: 'description', options: {}, type: 'TEXT_TYPE' },
+ { description: "User's home directory", name: 'directory', options: {}, type: 'TEXT_TYPE' },
+ { description: "User's configured default shell", name: 'shell', options: {}, type: 'TEXT_TYPE' },
+ { description: "User's UUID (Apple)", name: 'uuid', options: {}, type: 'TEXT_TYPE' },
+ ],
+ description: 'Local system users.',
+ examples: [
+ 'select * from users where uid = 1000',
+ "select * from users where username = 'root'",
+ 'select count(*) from users u, user_groups ug where u.uid = ug.uid',
+ ],
+ foreign_keys: [],
+ function: 'genUsers',
+ name: 'users',
+ profile: {},
+};
+
export default {
connectedComponent,
createAceSpy,
fillInFormInput,
reduxMockStore,
+ stubbedOsqueryTable,
};
diff --git a/frontend/test/mocks.js b/frontend/test/mocks.js
index f069294c01..27bd80ed96 100644
--- a/frontend/test/mocks.js
+++ b/frontend/test/mocks.js
@@ -12,6 +12,16 @@ export const validUser = {
gravatarURL: 'https://www.gravatar.com/avatar/7157f4758f8423b59aaee869d919f6b9',
};
+export const validCreateLabelRequest = (bearerToken, labelParams) => {
+ return nock('http://localhost:8080', {
+ reqHeaders: {
+ Authorization: `Bearer ${bearerToken}`,
+ },
+ })
+ .post('/api/v1/kolide/labels', JSON.stringify(labelParams))
+ .reply(201, { label: labelParams });
+};
+
export const validCreateQueryRequest = (bearerToken, queryParams) => {
return nock('http://localhost:8080', {
reqHeaders: {
@@ -224,6 +234,7 @@ export default {
invalidForgotPasswordRequest,
invalidGetQueryRequest,
invalidResetPasswordRequest,
+ validCreateLabelRequest,
validCreateQueryRequest,
validForgotPasswordRequest,
validGetConfigRequest,