From bb62993ea592bc15be61a7ec58d5978e879ca44f Mon Sep 17 00:00:00 2001 From: Mike Stone Date: Thu, 17 Nov 2016 12:12:41 -0500 Subject: [PATCH] Hosts side panel (#472) * Host side panel * Query form handles labels * QueryComposer handles labels * ManageHostsPage add label transitions * Stop preventing default on click outside of ellipsis menu * get labels from API * use real label data in hosts side panel * create label on label form submit * adds platform dropdown * Validate query text * Label header * validate presence of query text --- .../buttons/EllipsisMenu/EllipsisMenu.jsx | 3 +- .../forms/fields/Dropdown/Dropdown.jsx | 2 +- .../forms/queries/QueryForm/QueryForm.jsx | 180 +++++++++---- .../queries/QueryForm/QueryForm.tests.jsx | 12 +- .../forms/queries/QueryForm/helpers.js | 11 +- .../forms/validators/validate_query/index.js} | 7 +- .../validate_query/validate_query.tests.js | 43 +++ .../queries/QueryComposer/QueryComposer.jsx | 88 ++++--- .../QueryComposer/QueryComposer.tests.jsx | 16 +- .../HostSidePanel/HostSidePanel.jsx | 94 +++++++ .../HostSidePanel/PanelGroup/PanelGroup.jsx | 49 ++++ .../PanelGroup/PanelGroup.tests.jsx | 24 ++ .../HostSidePanel/PanelGroup/index.js | 1 + .../PanelGroupItem/PanelGroupItem.jsx | 39 +++ .../PanelGroupItem/PanelGroupItem.tests.jsx | 31 +++ .../HostSidePanel/PanelGroupItem/_styles.scss | 12 + .../HostSidePanel/PanelGroupItem/helpers.js | 18 ++ .../HostSidePanel/PanelGroupItem/index.js | 1 + .../side_panels/HostSidePanel/_styles.scss | 26 ++ .../side_panels/HostSidePanel/index.js | 1 + .../SecondarySidePanelContainer.jsx | 5 +- frontend/interfaces/label.js | 8 + frontend/kolide/endpoints.js | 1 + frontend/kolide/index.js | 34 +++ frontend/kolide/index.tests.js | 21 ++ .../hosts/ManageHostsPage/ManageHostsPage.jsx | 248 +++++++++++++++++- .../ManageHostsPage/ManageHostsPage.tests.jsx | 37 +++ .../pages/hosts/ManageHostsPage/_styles.scss | 20 ++ .../pages/queries/QueryPage/QueryPage.jsx | 8 +- .../pages/queries/QueryPage/helpers.tests.js | 39 --- .../components/ManageHostsPage/actions.js | 13 + .../components/ManageHostsPage/reducer.js | 17 ++ frontend/redux/nodes/components/reducer.js | 2 + frontend/redux/nodes/entities/base/schemas.js | 2 + .../redux/nodes/entities/labels/actions.js | 3 + .../redux/nodes/entities/labels/config.js | 12 + .../redux/nodes/entities/labels/reducer.js | 3 + frontend/redux/nodes/entities/reducer.js | 2 + frontend/test/helpers.jsx | 27 ++ frontend/test/mocks.js | 11 + 40 files changed, 1006 insertions(+), 165 deletions(-) rename frontend/{pages/queries/QueryPage/helpers.js => components/forms/validators/validate_query/index.js} (87%) create mode 100644 frontend/components/forms/validators/validate_query/validate_query.tests.js create mode 100644 frontend/components/side_panels/HostSidePanel/HostSidePanel.jsx create mode 100644 frontend/components/side_panels/HostSidePanel/PanelGroup/PanelGroup.jsx create mode 100644 frontend/components/side_panels/HostSidePanel/PanelGroup/PanelGroup.tests.jsx create mode 100644 frontend/components/side_panels/HostSidePanel/PanelGroup/index.js create mode 100644 frontend/components/side_panels/HostSidePanel/PanelGroupItem/PanelGroupItem.jsx create mode 100644 frontend/components/side_panels/HostSidePanel/PanelGroupItem/PanelGroupItem.tests.jsx create mode 100644 frontend/components/side_panels/HostSidePanel/PanelGroupItem/_styles.scss create mode 100644 frontend/components/side_panels/HostSidePanel/PanelGroupItem/helpers.js create mode 100644 frontend/components/side_panels/HostSidePanel/PanelGroupItem/index.js create mode 100644 frontend/components/side_panels/HostSidePanel/_styles.scss create mode 100644 frontend/components/side_panels/HostSidePanel/index.js create mode 100644 frontend/interfaces/label.js create mode 100644 frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.jsx delete mode 100644 frontend/pages/queries/QueryPage/helpers.tests.js create mode 100644 frontend/redux/nodes/components/ManageHostsPage/actions.js create mode 100644 frontend/redux/nodes/components/ManageHostsPage/reducer.js create mode 100644 frontend/redux/nodes/entities/labels/actions.js create mode 100644 frontend/redux/nodes/entities/labels/config.js create mode 100644 frontend/redux/nodes/entities/labels/reducer.js 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 (
-
-
+ {renderPlatformDropdown()} + {renderButtons()} ); } 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,