diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx index 3299cd88a0..4ffee58d2c 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.jsx @@ -2,8 +2,10 @@ import React, { PureComponent } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; import { push } from "react-router-redux"; +import { find, isEmpty, reduce, trim, union } from "lodash"; import Button from "components/buttons/Button"; +import Dropdown from "components/forms/fields/Dropdown"; import configInterface from "interfaces/config"; import HostSidePanel from "components/side_panels/HostSidePanel"; import LabelForm from "components/forms/LabelForm"; @@ -25,12 +27,10 @@ import entityGetter, { memoizedGetEntity } from "redux/utilities/entityGetter"; import { getLabels } from "redux/nodes/components/ManageHostsPage/actions"; import PATHS from "router/paths"; import deepDifference from "utilities/deep_difference"; -import { find } from "lodash"; import hostClient from "services/entities/hosts"; import permissionUtils from "utilities/permissions"; -import Dropdown from "components/forms/fields/Dropdown"; import { defaultHiddenColumns, generateVisibleTableColumns, @@ -45,10 +45,11 @@ import EditColumnsIcon from "../../../../assets/images/icon-edit-columns-16x12@2 import PencilIcon from "../../../../assets/images/icon-pencil-14x14@2x.png"; import TrashIcon from "../../../../assets/images/icon-trash-14x14@2x.png"; +const baseClass = "manage-hosts"; + const NEW_LABEL_HASH = "#new_label"; const EDIT_LABEL_HASH = "#edit_label"; const ALL_HOSTS_LABEL = "all-hosts"; -const baseClass = "manage-hosts"; const LABEL_SLUG_PREFIX = "labels/"; const HOST_SELECT_STATUSES = [ @@ -95,9 +96,17 @@ export class ManageHostsPage extends PureComponent { }), labels: PropTypes.arrayOf(labelInterface), loadingLabels: PropTypes.bool.isRequired, + queryParams: PropTypes.objectOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + routeTemplate: PropTypes.string, + routeParams: PropTypes.objectOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), enrollSecret: enrollSecretInterface, selectedFilters: PropTypes.arrayOf(PropTypes.string), selectedLabel: labelInterface, + selectedTeam: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), selectedOsqueryTable: osqueryTableInterface, statusLabels: statusLabelsInterface, loadingHosts: PropTypes.bool, @@ -105,6 +114,7 @@ export class ManageHostsPage extends PureComponent { canAddNewLabels: PropTypes.bool, teams: PropTypes.arrayOf(teamInterface), isGlobalAdmin: PropTypes.bool, + isOnGlobalTeam: PropTypes.bool, isBasicTier: PropTypes.bool, currentUser: userInterface, }; @@ -138,21 +148,21 @@ export class ManageHostsPage extends PureComponent { searchQuery: "", hosts: [], isHostsLoading: false, + isTeamsLoading: true, }; } componentDidMount() { - const { dispatch } = this.props; + const { dispatch, isBasicTier } = this.props; dispatch(getLabels()); + if (isBasicTier) { + dispatch(teamActions.loadAll({})); + } } componentDidUpdate(prevProps) { - const { dispatch, isBasicTier, canAddNewHosts } = this.props; - if ( - isBasicTier !== prevProps.isBasicTier && - isBasicTier && - canAddNewHosts - ) { + const { dispatch, isBasicTier } = this.props; + if (isBasicTier !== prevProps.isBasicTier && isBasicTier) { dispatch(teamActions.loadAll({})); } } @@ -212,6 +222,7 @@ export class ManageHostsPage extends PureComponent { toggleAddHostModal(); }; + // The onChange method below is for the dropdown used in modals onChangeTeam = (team) => { const { dispatch } = this.props; dispatch(teamActions.getEnrollSecrets(team)); @@ -227,7 +238,7 @@ export class ManageHostsPage extends PureComponent { sortDirection, }) => { const { retrieveHosts } = this; - const { selectedFilters } = this.props; + const { selectedFilters, selectedTeam } = this.props; let sortBy = []; if (sortHeader !== "") { @@ -243,6 +254,7 @@ export class ManageHostsPage extends PureComponent { selectedLabels: selectedFilters, globalFilter: searchQuery, sortBy, + teamId: selectedTeam, }); }; @@ -371,6 +383,56 @@ export class ManageHostsPage extends PureComponent { this.setState({ isAllMatchingHostsSelected: false }); }; + getNextLocationUrl = ( + pathPrefix = "", + newRouteTemplate = "", + newRouteParams = {}, + newQueryParams = {} + ) => { + const routeTemplate = newRouteTemplate || this.props.routeTemplate || ""; + const urlRouteParams = Object.assign( + {}, + this.props.routeParams, + newRouteParams + ); + const urlQueryParams = Object.assign( + {}, + this.props.queryParams, + newQueryParams + ); + + let routeString = ""; + + if (!isEmpty(urlRouteParams)) { + routeString = reduce( + urlRouteParams, + (string, value, key) => { + return string.replace(`:${key}`, encodeURIComponent(value)); + }, + routeTemplate + ); + } + + let queryString = ""; + if (!isEmpty(urlQueryParams)) { + queryString = reduce( + urlQueryParams, + (arr, value, key) => { + key && arr.push(`${key}=${encodeURIComponent(value)}`); + return arr; + }, + [] + ).join("&"); + } + + const nextLocation = union( + trim(pathPrefix, "/").split("/"), + routeString.split("/") + ).join("/"); + + return queryString ? `/${nextLocation}?${queryString}` : `/${nextLocation}`; + }; + getLabelSelected = () => { const { selectedFilters } = this.props; return selectedFilters.find((f) => f.includes(LABEL_SLUG_PREFIX)); @@ -381,6 +443,48 @@ export class ManageHostsPage extends PureComponent { return selectedFilters.find((f) => !f.includes(LABEL_SLUG_PREFIX)); }; + generateTeamFilterDropdownOptions = (teams) => { + const { currentUser, isOnGlobalTeam } = this.props; + + let currentUserTeams = []; + if (isOnGlobalTeam) { + currentUserTeams = teams; + } else if (currentUser && currentUser.teams) { + currentUserTeams = currentUser.teams; + } + + const allTeamsOption = [ + { + disabled: false, + label: "All teams", + value: 0, + }, + ]; + + const sortedCurrentUserTeamOptions = currentUserTeams + .map((team) => { + return { + disabled: false, + label: team.name, + value: team.id, + }; + }) + .sort((a, b) => { + const labelA = a.label.toUpperCase(); + const labelB = b.label.toUpperCase(); + if (labelA < labelB) { + return -1; + } + if (labelA > labelB) { + return 1; + } + + return 0; // values are equal + }); + + return allTeamsOption.concat(sortedCurrentUserTeamOptions); + }; + retrieveHosts = async (options) => { const { dispatch } = this.props; this.setState({ isHostsLoading: true }); @@ -407,12 +511,29 @@ export class ManageHostsPage extends PureComponent { ); }; - clearHostUpdates() { + isValidSelectedTeamId = (teamId) => { + const { currentUser, isOnGlobalTeam, teams } = this.props; + + let currentUserTeams = []; + if (isOnGlobalTeam) { + currentUserTeams = teams; + } else if (currentUser && currentUser.teams) { + currentUserTeams = currentUser.teams; + } + + const currentUserTeamIds = currentUserTeams.map((t) => t.id); + + teamId = parseInt(teamId, 10); + + return !isNaN(teamId) && teamId > 0 && currentUserTeamIds.includes(teamId); + }; + + clearHostUpdates = () => { if (this.timeout) { global.window.clearTimeout(this.timeout); this.timeout = null; } - } + }; toggleAddHostModal = () => { const { showAddHostModal } = this.state; @@ -441,12 +562,46 @@ export class ManageHostsPage extends PureComponent { } }; - handleLabelChange = ({ slug, type }) => { + // The handleChange method below is for the filter-by-team dropdown rather than the dropdown used in modals + handleChangeSelectedTeamFilter = (selectedTeam) => { const { dispatch, selectedFilters } = this.props; + const { searchQuery } = this.state; + const { getNextLocationUrl, isValidSelectedTeamId, retrieveHosts } = this; + const { MANAGE_HOSTS } = PATHS; + + let selectedTeamId = parseInt(selectedTeam, 10); + selectedTeamId = isValidSelectedTeamId(selectedTeamId) ? selectedTeamId : 0; + + let nextLocation = getNextLocationUrl( + MANAGE_HOSTS, + "", + {}, + { team_id: selectedTeamId } + ); + + if (!selectedTeamId) { + nextLocation = nextLocation.replace(`team_id=${selectedTeamId}`, ""); + } + + // TODO confirm that sort order, pagination work as expected + retrieveHosts({ + teamId: selectedTeam, + selectedLabels: selectedFilters, + globalFilter: searchQuery, + }); + dispatch(push(nextLocation)); + }; + + handleLabelChange = ({ slug, type }) => { + const { dispatch, selectedFilters, selectedTeam } = this.props; + const { isValidSelectedTeamId } = this; const { MANAGE_HOSTS } = PATHS; const isAllHosts = slug === ALL_HOSTS_LABEL; const newFilters = [...selectedFilters]; + let selectedTeamId = parseInt(selectedTeam, 10); + selectedTeamId = isValidSelectedTeamId(selectedTeamId) ? selectedTeamId : 0; + if (!isAllHosts) { // always remove "all-hosts" from the filters first because we don't want // something like ["label/8", "all-hosts"] @@ -468,9 +623,13 @@ export class ManageHostsPage extends PureComponent { } } - const nextLocation = isAllHosts + let nextLocation = isAllHosts ? MANAGE_HOSTS : `${MANAGE_HOSTS}/${newFilters.join("/")}`; + + if (selectedTeamId) { + nextLocation += `?team_id=${selectedTeamId}`; + } dispatch(push(nextLocation)); }; @@ -486,6 +645,37 @@ export class ManageHostsPage extends PureComponent { handleLabelChange(selected); }; + // TODO revisit UX for server errors for invalid team_id (e.g., team_id=0, team_id=null, team_id=foo, etc.) + renderTeamsFilterDropdown = () => { + const { isBasicTier, selectedTeam, teams } = this.props; + const { + generateTeamFilterDropdownOptions, + isValidSelectedTeamId, + handleChangeSelectedTeamFilter, + } = this; + const teamOptions = generateTeamFilterDropdownOptions(teams); + + let selectedTeamId = parseInt(selectedTeam, 10); + selectedTeamId = isValidSelectedTeamId(selectedTeamId) ? selectedTeamId : 0; + + return isBasicTier ? ( +
+ + handleChangeSelectedTeamFilter(newSelectedValue) + } + /> +
+ ) : ( +

Hosts

+ ); + }; + renderEditColumnsModal = () => { const { config, currentUser } = this.props; const { showEditColumnsModal, hiddenColumns } = this.state; @@ -608,22 +798,16 @@ export class ManageHostsPage extends PureComponent { }; renderHeader = () => { - const { renderHeaderLabelBlock } = this; + const { renderHeaderLabelBlock, renderTeamsFilterDropdown } = this; const { isAddLabel, selectedLabel } = this.props; - - if (!selectedLabel || isAddLabel) { - return false; - } - - const { type } = selectedLabel; + const type = selectedLabel?.type; return (
-

- Hosts -

+ {renderTeamsFilterDropdown()} {type !== "all" && type !== "status" && + selectedLabel && renderHeaderLabelBlock(selectedLabel)}
@@ -833,7 +1017,12 @@ export class ManageHostsPage extends PureComponent { } } -const mapStateToProps = (state, { location, params }) => { +const mapStateToProps = (state, ownProps) => { + const { location, params, route, routeParams } = ownProps; + const locationPath = location.path; + const queryParams = location.query; + const routeTemplate = route && route.path ? route.path : ""; + const { active_label: activeLabel, label_id: labelID } = params; const selectedFilters = []; @@ -866,6 +1055,12 @@ const mapStateToProps = (state, { location, params }) => { const { loading: loadingHosts } = state.entities.hosts; + const { loading: loadingTeams } = state.entities.teams; + const teams = memoizedGetEntity(state.entities.teams.data); + + // If there is no team_id, set selectedTeam to 0 so dropdown defaults to "All teams" + const selectedTeam = location.query?.team_id || 0; + const currentUser = state.auth.user; const canAddNewHosts = permissionUtils.isGlobalAdmin(currentUser) || @@ -875,11 +1070,15 @@ const mapStateToProps = (state, { location, params }) => { permissionUtils.isGlobalAdmin(currentUser) || permissionUtils.isGlobalMaintainer(currentUser); const isGlobalAdmin = permissionUtils.isGlobalAdmin(currentUser); + const isOnGlobalTeam = permissionUtils.isOnGlobalTeam(currentUser); const isBasicTier = permissionUtils.isBasicTier(config); - const teams = memoizedGetEntity(state.entities.teams.data); return { selectedFilters, + locationPath, + queryParams, + routeParams, + routeTemplate, isAddLabel, isEditLabel, labelErrors, @@ -895,8 +1094,11 @@ const mapStateToProps = (state, { location, params }) => { canAddNewHosts, canAddNewLabels, isGlobalAdmin, + isOnGlobalTeam, isBasicTier, teams, + loadingTeams, + selectedTeam, }; }; diff --git a/frontend/pages/hosts/ManageHostsPage/_styles.scss b/frontend/pages/hosts/ManageHostsPage/_styles.scss index 6b7cdc3229..8fbcee64be 100644 --- a/frontend/pages/hosts/ManageHostsPage/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/_styles.scss @@ -13,6 +13,10 @@ &__header { display: flex; align-items: center; + + .form-field { + margin-bottom: 0; + } } &__text { @@ -111,6 +115,7 @@ .form-field--dropdown { margin: 0; } + &__status_dropdown { width: 159px; @@ -123,4 +128,90 @@ } } } + + &__team-dropdown { + border: 0 !important; + position: relative; + + :hover { + cursor: pointer !important; + } + + &.is-focused { + .Select-control { + border: 0 !important; + height: 32px; + } + } + + .Select-menu-outer { + position: absolute; + width: 330px; + min-width: 125px; + left: -12px; + border-radius: 6px; + } + } + + &__header { + .Select-control { + background-color: #fff; + border: 0 !important; + border-radius: none; + position: none; + width: max-content; // move select arrow + height: 20px; + + &:hover { + box-shadow: none; + } + + &:hover .Select-value-label { + color: $core-vibrant-blue !important; + } + + .Select-arrow-zone { + padding-left: $pad-small; + + .Select-arrow { + top: 1px !important; + margin-top: 0 !important; + } + } + + .Select-multi-value-wrapper { + width: max-content; // move select arrow + height: 20px; + margin-bottom: $pad-xsmall; + + .Select-input { + display: none !important; + } + + .Select-value { + position: relative; // move select arrow + display: inline-block; // move select arrow + line-height: 28px; + padding: 0; + border: 0 !important; + background-color: #fff !important; + right: 0; + left: 0; + bottom: 0; + top: 0; + + &.is-focused { + border: 0 !important; + } + :hover { + border: 0 !important; + } + + .Select-value-label { + font-size: $large !important; + } + } + } + } + } } diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index fd0eee0603..110108b0c3 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -13,6 +13,7 @@ interface IHostLoadOptions { selectedLabels: string[]; globalFilter: string; sortBy: ISortOption[]; + teamId: number; } export default { @@ -41,6 +42,7 @@ export default { const selectedLabels = options?.selectedLabels || []; const globalFilter = options?.globalFilter || ""; const sortBy = options?.sortBy || []; + const teamId = options?.teamId || null; // TODO: add this query param logic to client class const pagination = `page=${page}&per_page=${perPage}`; @@ -86,6 +88,10 @@ export default { path = `${HOSTS}?${pagination}${searchQuery}${orderKeyParam}${orderDirection}`; } + if (teamId) { + path += `&team_id=${teamId}`; + } + return sendRequest("GET", path); }, transferToTeam: (teamId: number | null, hostIds: number[]) => {