diff --git a/.gitignore b/.gitignore index 11c0ea93c6..bcb749e20c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ helm-temp #editors .vscode +.idea diff --git a/assets/images/icon-edit-columns-20x20@2x.png b/assets/images/icon-edit-columns-20x20@2x.png new file mode 100644 index 0000000000..748cb9ca9e Binary files /dev/null and b/assets/images/icon-edit-columns-20x20@2x.png differ diff --git a/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/EditColumnsModal.jsx b/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/EditColumnsModal.jsx new file mode 100644 index 0000000000..b6b8fe9f23 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/EditColumnsModal.jsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import Checkbox from '../../../../../components/forms/fields/Checkbox'; +import Button from '../../../../../components/buttons/Button'; + +const useCheckboxListStateManagement = (allColumns, hiddenColumns) => { + const [columnItems, setColumnItems] = useState(() => { + return allColumns.map((column) => { + return { + name: column.title, + accessor: column.accessor, + isChecked: !hiddenColumns.includes(column.accessor), + }; + }); + }); + + const updateColumnItems = (columnAccessor) => { + setColumnItems((prevState) => { + const selectedColumn = columnItems.find(column => column.accessor === columnAccessor); + const updatedColumn = { + ...selectedColumn, + isChecked: !selectedColumn.isChecked, + }; + + // this is replacing the column object with the updatedColumn we just created. + const newState = prevState.map((currentColumn) => { + return currentColumn.accessor === columnAccessor ? updatedColumn : currentColumn; + }); + return newState; + }); + }; + + return [columnItems, updateColumnItems]; +}; + +const getHiddenColumns = (columns) => { + return columns.filter(column => !column.isChecked) + .map(column => column.accessor); +}; + +const EditColumnsModal = (props) => { + const { columns, hiddenColumns, onSaveColumns, onCancelColumns } = props; + const [columnItems, updateColumnItems] = useCheckboxListStateManagement(columns, hiddenColumns); + + return ( +
+

Choose which columns you see

+
+ {columnItems.map((column) => { + return ( +
+ updateColumnItems(column.accessor)} + > + {column.name} + +
+ ); + })} +
+
+ + +
+
+ ); +}; + +EditColumnsModal.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object), // TODO: create proper interface for this + hiddenColumns: PropTypes.arrayOf(PropTypes.string), + onSaveColumns: PropTypes.func, + onCancelColumns: PropTypes.func, +}; + +export default EditColumnsModal; diff --git a/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/_styles.scss new file mode 100644 index 0000000000..4944c3a9f4 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/EditColumnsModal/_styles.scss @@ -0,0 +1,11 @@ +.edit-column-modal { + + .button-actions { + display: flex; + justify-content: flex-end; + + .save-button { + margin-left: $pad-half; + } + } +} diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostContainer.jsx b/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostContainer.jsx index 3d6e8129b3..37028cbe15 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostContainer.jsx +++ b/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostContainer.jsx @@ -1,12 +1,18 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import ReactTooltip from 'react-tooltip'; import labelInterface from 'interfaces/label'; +import Button from 'components/buttons/Button'; import InputField from 'components/forms/fields/InputField'; import KolideIcon from 'components/icons/KolideIcon'; +import Modal from 'components/modals/Modal'; import RoboDogImage from '../../../../../../assets/images/robo-dog-176x144@2x.png'; +import EditColumnsIcon from '../../../../../../assets/images/icon-edit-columns-20x20@2x.png'; +import { hostDataHeaders, defaultHiddenColumns } from './HostTableConfig'; import HostsDataTable from '../HostsDataTable/HostsDataTable'; +import EditColumnsModal from '../EditColumnsModal/EditColumnsModal'; const baseClass = 'host-container'; @@ -20,13 +26,18 @@ class HostContainer extends Component { static defaultProps = { selectedLabel: { count: undefined }, - }; + } constructor (props) { super(props); + // For now we persist using localstorage. May do server side persistence later. + const storedHiddenColumns = JSON.parse(localStorage.getItem('hostHiddenColumns')); + this.state = { searchQuery: '', + showEditColumnsModal: false, + hiddenColumns: storedHiddenColumns !== null ? storedHiddenColumns : defaultHiddenColumns, }; } @@ -36,14 +47,55 @@ class HostContainer extends Component { }); } + onEditColumnsClick = () => { + this.setState({ + showEditColumnsModal: true, + }); + } + + onCancelColumns = () => { + this.setState({ + showEditColumnsModal: false, + }); + } + + onSaveColumns = (newHiddenColumns) => { + localStorage.setItem('hostHiddenColumns', JSON.stringify(newHiddenColumns)); + this.setState({ + hiddenColumns: newHiddenColumns, + showEditColumnsModal: false, + }); + } + + renderEditColumnsModal = () => { + const { showEditColumnsModal, hiddenColumns } = this.state; + + if (!showEditColumnsModal) return null; + + return ( + this.setState({ showEditColumnsModal: false })} + className={`${baseClass}__invite-modal`} + > + + + ); + } + render () { - const { onSearchQueryChange } = this; + const { onSearchQueryChange, renderEditColumnsModal } = this; const { selectedFilter, selectedLabel } = this.props; - const { searchQuery } = this.state; + const { searchQuery, hiddenColumns } = this.state; if (selectedFilter === 'all-hosts' && selectedLabel.count === 0) { return ( -
+
No Hosts
@@ -62,20 +114,33 @@ class HostContainer extends Component { return (
-
- - + {/* TODO: find a way to move these controls into the table component */} +
+ +
+ + +
+ + Search by hostname, UUID, serial number, or IPv4 +
+ {renderEditColumnsModal()}
); } diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostTableConfig.jsx b/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostTableConfig.jsx new file mode 100644 index 0000000000..2bb0ce9935 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/HostContainer/HostTableConfig.jsx @@ -0,0 +1,104 @@ +import React from 'react'; + +import HeaderCell from '../HeaderCell/HeaderCell'; +import LinkCell from '../LinkCell/LinkCell'; +import StatusCell from '../StatusCell/StatusCell'; +import TextCell from '../TextCell/TextCell'; +import { humanHostMemory, humanHostUptime, humanHostLastSeen } from '../../../../../kolide/helpers'; + +const hostDataHeaders = [ + { + title: 'Hostname', + Header: cellProps => , + accessor: 'hostname', + Cell: cellProps => , + canHide: false, + }, + { + title: 'Status', + Header: 'Status', + disableSortBy: true, + accessor: 'status', + Cell: cellProps => , + }, + { + title: 'OS', + Header: cellProps => , + accessor: 'os_version', + Cell: cellProps => , + }, + { + title: 'Osquery', + Header: cellProps => , + accessor: 'osquery_version', + Cell: cellProps => , + }, + { + title: 'IPv4', + Header: cellProps => , + accessor: 'primary_ip', + Cell: cellProps => , + }, + { + title: 'Last Seen', + Header: cellProps => , + accessor: 'seen_time', + Cell: cellProps => , + }, + { + title: 'UUID', + Header: cellProps => , + accessor: 'uuid', + Cell: cellProps => , + }, + { + title: 'Uptime', + Header: cellProps => , + accessor: 'uptime', + Cell: cellProps => , + }, + { + title: 'CPU', + Header: 'CPU', + disableSortBy: true, + accessor: 'host_cpu', + Cell: cellProps => , + }, + { + title: 'Memory', + Header: cellProps => , + accessor: 'memory', + Cell: cellProps => , + }, + { + title: 'MAC Address', + Header: cellProps => , + accessor: 'primary_mac', + Cell: cellProps => , + }, + { + title: 'Serial Number', + Header: cellProps => , + accessor: 'hardware_serial', + Cell: cellProps => , + }, + { + title: 'Hardware Model', + Header: cellProps => , + accessor: 'hardware_model', + Cell: cellProps => , + }, +]; + +const defaultHiddenColumns = [ + 'primary_mac', + 'host_cpu', + 'memory', + 'uptime', + 'uuid', + 'seen_time', + 'hardware_model', + 'hardware_serial', +]; + +export { hostDataHeaders, defaultHiddenColumns }; diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostContainer/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/HostContainer/_styles.scss index f552cbadc4..6579c18582 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostContainer/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/components/HostContainer/_styles.scss @@ -3,15 +3,8 @@ &--no-hosts { display: flex; flex-direction: column; - justify-content: center; - padding-top: 35px; - font-size: 15px; - font-weight: $regular; - line-height: 2; - letter-spacing: normal; - color: rgba(32, 37, 50, 0.66); - border-top: 1px solid $ui-borders; - margin-top: $pad-half; + align-items: center; + margin-top: 80px; h1 { font-size: $large; @@ -47,8 +40,13 @@ &__inner { display: flex; - align-items: center; - justify-content: center; + flex-direction: row; + + h1 { + font-size: $small; + font-weight: $bold; + margin-bottom: $pad-medium; + } img { width: 176px; @@ -61,6 +59,12 @@ font-size: $x-small; margin: 0; } + + .no-filter-results { + display: flex; + flex-direction: column; + width: 350px; + } } .host-pagination__pager-wrap { @@ -96,11 +100,35 @@ } } + &__table-controls { + display: flex; + justify-content: flex-end; + align-items: center; + } + + &__edit-columns-button { + display: flex; + align-items: center; + font-size: $x-small; + color: $core-blue; + + img { + width: 20px; + margin-right: $pad-half; + } + } + + &__edit-columns-button:hover { + cursor: pointer; + text-decoration: underline; + color: $core-blue-over; + } + &__search-input { position: relative; color: $core-dark-blue-grey; width: 344px; - margin-left: auto; + margin-left: $pad-medium; .host-container__input-wrapper { margin-bottom: 0; diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsDataTable/HostsDataTable.jsx b/frontend/pages/hosts/ManageHostsPage/components/HostsDataTable/HostsDataTable.jsx index 8c0ca618cd..646fdfa69b 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostsDataTable/HostsDataTable.jsx +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsDataTable/HostsDataTable.jsx @@ -3,18 +3,11 @@ import PropTypes from 'prop-types'; import { useTable, useGlobalFilter, useSortBy, useAsyncDebounce } from 'react-table'; import { useSelector, useDispatch } from 'react-redux'; - // TODO: move this file closer to HostsDataTable -import { humanHostMemory, humanHostUptime } from 'kolide/helpers'; import { getHostTableData } from 'redux/nodes/components/ManageHostsPage/actions'; import Spinner from 'components/loaders/Spinner'; import HostPagination from 'components/hosts/HostPagination'; - -import HeaderCell from '../HeaderCell/HeaderCell'; -import TextCell from '../TextCell/TextCell'; -import StatusCell from '../StatusCell/StatusCell'; -import LinkCell from '../LinkCell/LinkCell'; import scrollToTop from '../../../../../utilities/scroll_to_top'; // TODO: pass in as props @@ -42,6 +35,8 @@ const HostsDataTable = (props) => { // component cannot access the router state. selectedFilter, searchQuery, + hiddenColumns, + tableColumns, } = props; const [pageSize] = useState(DEFAULT_PAGE_SIZE); @@ -59,20 +54,9 @@ const HostsDataTable = (props) => { const pageIndexChangeRef = useRef(); - // TODO: maybe pass as props? const columns = useMemo(() => { - return [ - { Header: cellProps => , accessor: 'hostname', Cell: cellProps => }, - { Header: 'Status', disableSortBy: true, accessor: 'status', Cell: cellProps => }, - { Header: cellProps => , accessor: 'os_version', Cell: cellProps => }, - { Header: cellProps => , accessor: 'osquery_version', Cell: cellProps => }, - { Header: cellProps => , accessor: 'primary_ip', Cell: cellProps => }, - { Header: cellProps => , accessor: 'primary_mac', Cell: cellProps => }, - { Header: 'CPU', disableSortBy: true, accessor: 'host_cpu', Cell: cellProps => }, - { Header: cellProps => , accessor: 'memory', Cell: cellProps => }, - { Header: cellProps => , accessor: 'uptime', Cell: cellProps => }, - ]; - }, []); + return tableColumns; + }, [tableColumns]); const data = useMemo(() => { return hostAPIOrder.map((id) => { @@ -85,13 +69,16 @@ const HostsDataTable = (props) => { rows, prepareRow, setGlobalFilter, + setHiddenColumns, state: tableState, } = useTable( { columns, data, initialState: { sortBy: [{ id: DEFAULT_SORT_KEY, desc: true }], + hiddenColumns, }, + autoResetHiddenColumns: false, disableMultiSort: true, manualGlobalFilter: true, manualSortBy: true, @@ -110,28 +97,34 @@ const HostsDataTable = (props) => { const onPaginationChange = useCallback((newPage) => { if (newPage > pageIndex) { - // pageIndexChangeRef.current = pageIndex; setPageIndex(pageIndex + 1); } else { - // pageIndpageIndexChangeRefexRef.current = pageIndex; setPageIndex(pageIndex - 1); } pageIndexChangeRef.current = true; scrollToTop(); }, [pageIndex, setPageIndex]); - // Since searchQuery is feed in from the parent, we want to debounce the globalfilter change + // Since searchQuery is passed in from the parent, we want to debounce the globalFilter change // when we see it change. useEffect(() => { debouncedGlobalFilter(searchQuery); }, [debouncedGlobalFilter, searchQuery]); - // Any changes to these relevent table search params will fire off an action to get the new + // Track hidden columns changing and update the table accordingly. + useEffect(() => { + setHiddenColumns(hiddenColumns); + }, [setHiddenColumns, hiddenColumns]); + + // Any changes to these relevant table search params will fire off an action to get the new // hosts data. useEffect(() => { if (pageIndexChangeRef.current) { // the pageIndex has changed dispatch(getHostTableData(pageIndex, pageSize, selectedFilter, globalFilter, sortBy)); - } else { + } else { // something besides pageIndex changed. we want to get results starting at the first page + // NOTE: currently this causes the request to fire twice if the user is not on the first page + // of results. Need to come back to this and figure out how to get it to + // only fire once. setPageIndex(0); dispatch(getHostTableData(0, pageSize, selectedFilter, globalFilter, sortBy)); } @@ -144,18 +137,11 @@ const HostsDataTable = (props) => { return (
-
-

No hosts match the current search criteria

+
+

No hosts match the current criteria

Expecting to see new hosts? Try again in a few seconds as the system catches up

- -
); } @@ -166,6 +152,11 @@ const HostsDataTable = (props) => {

{generateHostCountText(pageIndex, pageSize, rows.length)}

+ {loadingHosts && +
+ +
+ } {headerGroups.map(headerGroup => ( @@ -179,22 +170,20 @@ const HostsDataTable = (props) => { ))} - {loadingHosts - ? - : rows.map((row) => { - prepareRow(row); - return ( - - {row.cells.map((cell) => { - return ( - - ); - })} - - ); - }) + {rows.map((row) => { + prepareRow(row); + return ( + + {row.cells.map((cell) => { + return ( + + ); + })} + + ); + }) }
- {cell.render('Cell')} -
+ {cell.render('Cell')} +
@@ -213,6 +202,8 @@ const HostsDataTable = (props) => { HostsDataTable.propTypes = { selectedFilter: PropTypes.string, searchQuery: PropTypes.string, + tableColumns: PropTypes.arrayOf(PropTypes.object), // TODO: create proper interface for this + hiddenColumns: PropTypes.arrayOf(PropTypes.string), }; export default HostsDataTable; diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsDataTable/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/HostsDataTable/_styles.scss index 5c39b7ef32..b50104c81a 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostsDataTable/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsDataTable/_styles.scss @@ -86,5 +86,15 @@ } } } + + .loading-overlay { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: rgba(255, 255, 255, 0.8); + z-index: 1; + } } }