diff --git a/changes/issue-2050-software-filter b/changes/issue-2050-software-filter new file mode 100644 index 0000000000..9a421b98c2 --- /dev/null +++ b/changes/issue-2050-software-filter @@ -0,0 +1,4 @@ +* Add new UI feature: filter hosts by software version +* Move specific CVE information to appear alongside aggregate hosts filtered by software version +rather than on individual host details page +* Relocate users table to below software inventory on host details page \ No newline at end of file diff --git a/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx index cb02628b83..6a09b647fe 100644 --- a/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx @@ -24,7 +24,7 @@ import ReactTooltip from "react-tooltip"; import Spinner from "components/loaders/Spinner"; import Button from "components/buttons/Button"; import Modal from "components/modals/Modal"; // @ts-ignore -import SoftwareVulnerabilities from "pages/hosts/HostDetailsPage/SoftwareVulnerabilities"; // @ts-ignore +import SoftwareVulnerabilities from "pages/hosts/HostDetailsPage/SoftwareVulnCount"; // @ts-ignore import HostUsersListRow from "pages/hosts/HostDetailsPage/HostUsersListRow"; import TableContainer from "components/TableContainer"; @@ -522,7 +522,9 @@ const HostDetailsPage = ({ ) : ( <> - + {host?.software && ( + + )} {host?.software && ( {renderLabels()} {renderPacks()} - {renderUsers()} {host?.software && renderSoftware()} + {renderUsers()} {showDeleteHostModal && renderDeleteHostModal()} {showQueryHostModal && ( { // sortType: "dateStrings", // }, // TODO: Enable after manage hosts page has been updated to filter hosts by software id - // { - // title: "", - // Header: "", - // disableSortBy: true, - // accessor: "linkToFilteredHosts", - // Cell: (cellProps) => { - // return ( - // - // link to hosts filtered by software ID - // - // ); - // }, - // disableHidden: true, - // }, + { + title: "", + Header: "", + disableSortBy: true, + accessor: "linkToFilteredHosts", + Cell: (cellProps) => { + return ( + + link to hosts filtered by software ID + + ); + }, + disableHidden: true, + }, ]; }; diff --git a/frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/SoftwareVulnerabilities-1.tsx b/frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/SoftwareVulnCount.tsx similarity index 83% rename from frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/SoftwareVulnerabilities-1.tsx rename to frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/SoftwareVulnCount.tsx index 15ecc95641..d96ed7d810 100644 --- a/frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/SoftwareVulnerabilities-1.tsx +++ b/frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/SoftwareVulnCount.tsx @@ -1,5 +1,3 @@ -// TODO: Replace SoftwareVulnerabilities.jsx with this file upon completion of changes to ManageHostsPage planned that will display vulnerability information along with filter-by-software-id functionality - import React from "react"; import { ISoftware } from "interfaces/software"; diff --git a/frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/_styles.scss b/frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/_styles.scss similarity index 80% rename from frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/_styles.scss rename to frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/_styles.scss index ad33a4db8c..fe9eed4dc2 100644 --- a/frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/_styles.scss +++ b/frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/_styles.scss @@ -11,18 +11,14 @@ p { padding-left: $pad-large; - } - - a { - color: $core-vibrant-blue; - font-weight: $bold; - text-decoration: none; + margin-top: $pad-medium; + margin-bottom: $pad-medium; } img { height: 16px; width: auto; - padding-right: $pad-small; + padding-right: 10px; } &__count { diff --git a/frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/index.ts b/frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/index.ts new file mode 100644 index 0000000000..fca34bbc90 --- /dev/null +++ b/frontend/pages/hosts/HostDetailsPage/SoftwareVulnCount/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareVulnCount"; diff --git a/frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/SoftwareVulnerabilities.jsx b/frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/SoftwareVulnerabilities.jsx deleted file mode 100644 index 54614fbd79..0000000000 --- a/frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/SoftwareVulnerabilities.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; - -import softwareInterface from "interfaces/software"; -import FleetIcon from "../../../../../assets/images/open-new-tab-12x12@2x.png"; - -const baseClass = "software-vulnerabilities"; - -class SoftwareVulnerabilities extends Component { - static propTypes = { - softwareList: PropTypes.arrayOf(softwareInterface), - }; - - render() { - const { softwareList } = this.props; - - const vulsList = []; - - const vulnerabilitiesListMaker = () => { - softwareList.forEach((software) => { - if (software.vulnerabilities) { - software.vulnerabilities.forEach((vulnerability) => { - vulsList.push({ - name: software.name, - cve: vulnerability.cve, - details_link: vulnerability.details_link, - }); - }); - } - }); - }; - - vulnerabilitiesListMaker(); - - const renderVulsCount = (list) => { - if (list.length === 1) { - return "1 vulnerability detected"; - } - return `${list.length} vulnerabilities detected`; - }; - - const renderVul = (vul, index) => { - return ( -
  • - Read more about {vul.name}{" "} - - {vul.cve} vulnerability   - External link - -
  • - ); - }; - - // No software vulnerabilities - if (vulsList.length === 0) { - return null; - } - - // Software vulnerabilities - return ( -
    -
    - - - -   - {renderVulsCount(vulsList)} -
    -
    -
      {vulsList.map((vul, index) => renderVul(vul, index))}
    -
    -
    - ); - } -} - -export default SoftwareVulnerabilities; diff --git a/frontend/pages/hosts/HostDetailsPage/_styles.scss b/frontend/pages/hosts/HostDetailsPage/_styles.scss index d60dfa3c83..7092ce441d 100644 --- a/frontend/pages/hosts/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/HostDetailsPage/_styles.scss @@ -380,11 +380,10 @@ width: 16px; padding-right: 0px; } - // TODO: Enable for link to filtered hosts page after manage hosts page has been updated to filter hosts by software id - // &:last-child { - // width: 90px; - // padding: 0px; - // } + &:last-child { + width: 90px; + padding: 0px; + } } tr { td { diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 0f1fd97d82..9a8f047da4 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -1,9 +1,10 @@ -import React, { useState, useContext, useEffect } from "react"; +import React, { useState, useContext } from "react"; import { useDispatch } from "react-redux"; import { useQuery } from "react-query"; import { InjectedRouter, Params } from "react-router/lib/Router"; import { RouteProps } from "react-router/lib/Route"; -import { find, isEmpty, isEqual, memoize, omit } from "lodash"; +import { find, isEmpty, isEqual, omit } from "lodash"; +import ReactTooltip from "react-tooltip"; import labelsAPI from "services/entities/labels"; import statusLabelsAPI from "services/entities/statusLabels"; @@ -26,6 +27,7 @@ import { IStatusLabels } from "interfaces/status_labels"; import { ITeam } from "interfaces/team"; import { IHost } from "interfaces/host"; import { IPolicy } from "interfaces/policy"; +import { ISoftware } from "interfaces/software"; import { useDeepEffect } from "utilities/hooks"; // @ts-ignore import deepDifference from "utilities/deep_difference"; import { @@ -69,6 +71,7 @@ import PoliciesFilter from "./components/PoliciesFilter"; // @ts-ignore import EditColumnsModal from "./components/EditColumnsModal/EditColumnsModal"; import TransferHostModal from "./components/TransferHostModal"; import DeleteHostModal from "./components/DeleteHostModal"; +import SoftwareVulnerabilities from "./components/SoftwareVulnerabilities"; import GenerateInstallerModal from "./components/GenerateInstallerModal"; import EditColumnsIcon from "../../../../assets/images/icon-edit-columns-16x16@2x.png"; import PencilIcon from "../../../../assets/images/icon-pencil-14x14@2x.png"; @@ -187,6 +190,9 @@ const ManageHostsPage = ({ const [hasHostCountErrors, setHasHostCountErrors] = useState(false); const [sortBy, setSortBy] = useState(initialSortBy); const [policy, setPolicy] = useState(); + const [softwareDetails, setSoftwareDetails] = useState( + null + ); const [tableQueryData, setTableQueryData] = useState(); // ======== end states @@ -195,6 +201,7 @@ const ManageHostsPage = ({ const routeTemplate = route && route.path ? route.path : ""; const policyId = queryParams?.policy_id; const policyResponse: PolicyResponse = queryParams?.policy_response; + const softwareId = parseInt(queryParams?.software_id, 10); const { active_label: activeLabel, label_id: labelID } = routeParams; // ===== filter matching @@ -311,8 +318,11 @@ const ManageHostsPage = ({ }; try { - const { hosts: returnedHosts } = await hostsAPI.loadAll(options); + const { hosts: returnedHosts, software } = await hostsAPI.loadAll( + options + ); setHosts(returnedHosts); + software && setSoftwareDetails(software); } catch (error) { console.error(error); setHasHostErrors(true); @@ -378,6 +388,7 @@ const ManageHostsPage = ({ teamId: selectedTeam?.id, policyId, policyResponse, + softwareId, }; if (tableQueryData) { @@ -411,6 +422,7 @@ const ManageHostsPage = ({ teamId: selectedTeam?.id, policyId, policyResponse, + softwareId, }; retrieveHostCount(options); @@ -420,6 +432,7 @@ const ManageHostsPage = ({ policyId, policyResponse, selectedFilters, + softwareId, ]); const handleLabelChange = ({ slug }: ILabel) => { @@ -453,10 +466,15 @@ const ManageHostsPage = ({ } } - // Non-status labels are not compatible with policies so omit policy params from next location + // Non-status labels are not compatible with policies or software filters + // so omit policies and software params from next location let newQueryParams = queryParams; if (newFilters.find((f) => f.includes(LABEL_SLUG_PREFIX))) { - newQueryParams = omit(newQueryParams, ["policy_id", "policy_response"]); + newQueryParams = omit(newQueryParams, [ + "policy_id", + "policy_response", + "software_id", + ]); } router.replace( @@ -494,6 +512,21 @@ const ManageHostsPage = ({ ); }; + const handleClearSoftwareFilter = () => { + // TODO: In current UX, clearing the software filter resets all URL params. + // The code below can be reimplemented if other URL params are to be preserved. + // router.replace( + // getNextLocationPath({ + // pathPrefix: PATHS.MANAGE_HOSTS, + // routeTemplate, + // routeParams, + // queryParams: omit(queryParams, ["software_id"]), + // }) + // ); + router.replace(PATHS.MANAGE_HOSTS); + setSoftwareDetails(null); + }; + // The handleChange method below is for the filter-by-team dropdown rather than the dropdown used in modals const handleChangeSelectedTeamFilter = (selectedTeam: number) => { const { MANAGE_HOSTS } = PATHS; @@ -627,6 +660,10 @@ const ManageHostsPage = ({ newQueryParams.policy_response = policyResponse; } + if (softwareId && !policyId) { + newQueryParams.software_id = softwareId; + } + // triggers useDeepEffect using queryParams router.replace( getNextLocationPath({ @@ -771,6 +808,7 @@ const ManageHostsPage = ({ teamId: currentTeam?.id, policyId, policyResponse, + softwareId, }); toggleTransferHostModal(); @@ -816,6 +854,7 @@ const ManageHostsPage = ({ teamId: currentTeam?.id, policyId, policyResponse, + softwareId, }); refetchLabels(); @@ -897,6 +936,49 @@ const ManageHostsPage = ({ ); }; + const renderSoftwareFilterBlock = () => { + if (softwareDetails) { + const { name, version } = softwareDetails; + const buttonText = name && version ? `${name} ${version}` : ""; + return ( +
    + +
    + ); + } + return null; + }; + const renderEditColumnsModal = () => { if (!showEditColumnsModal || !config || !currentUser) { return null; @@ -1012,33 +1094,39 @@ const ManageHostsPage = ({ ); }; - const renderHeaderLabelBlock = ({ - description, - display_text: displayText, - label_type: labelType, - }: ILabel) => { - displayText = PLATFORM_LABEL_DISPLAY_NAMES[displayText] || displayText; + const renderHeaderLabelBlock = () => { + if (selectedLabel) { + const { + description, + display_text: displayText, + label_type: labelType, + } = selectedLabel; - return ( -
    -
    - {displayText} - {labelType !== "builtin" && !isOnlyObserver && ( - <> - - - - )} + return ( +
    +
    + + {PLATFORM_LABEL_DISPLAY_NAMES[displayText] || displayText} + + {labelType !== "builtin" && !isOnlyObserver && ( + <> + + + + )} +
    +
    + {description} +
    -
    - {description} -
    -
    - ); + ); + } + + return null; }; const renderHeader = () => { @@ -1051,24 +1139,36 @@ const ManageHostsPage = ({ ); }; - const renderLabelOrPolicyBlock = () => { - const type = selectedLabel?.type; - - if (policyId || selectedLabel) { + const renderActiveFilterBlock = () => { + const showSelectedLabel = + selectedLabel && + selectedLabel.type !== "all" && + selectedLabel.type !== "status"; + if (policyId || softwareId || showSelectedLabel) { return ( -
    - {policyId && renderPoliciesFilterBlock()} - {!policyId && - type !== "all" && - type !== "status" && - selectedLabel && - renderHeaderLabelBlock(selectedLabel)} +
    + {showSelectedLabel && renderHeaderLabelBlock()} + {!!policyId && + !softwareId && + !showSelectedLabel && + renderPoliciesFilterBlock()} + {!!softwareId && + !policyId && + !showSelectedLabel && + renderSoftwareFilterBlock()}
    ); } return null; }; + const renderSoftwareVulnerabilities = () => { + if (softwareDetails) { + return ; + } + return null; + }; + const renderForm = () => { if (isAddLabel) { return ( @@ -1261,7 +1361,8 @@ const ManageHostsPage = ({ )}
    - {renderLabelOrPolicyBlock()} + {renderActiveFilterBlock()} + {renderSoftwareVulnerabilities()} {config && (!isPremiumTier || teams) && renderTable(selectedTeam)} )} diff --git a/frontend/pages/hosts/ManageHostsPage/_styles.scss b/frontend/pages/hosts/ManageHostsPage/_styles.scss index 64981c1093..8dd1f683f9 100644 --- a/frontend/pages/hosts/ManageHostsPage/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/_styles.scss @@ -241,7 +241,7 @@ } } - &__labels-policies-wrap { + &__labels-active-filter-wrap { margin-bottom: $pad-medium; } @@ -260,4 +260,19 @@ padding: $pad-small; margin-right: $pad-small; } + + &__software-filter-block { + .button--small-text-icon { + margin-left: 0; + padding-left: 12px; + } + .software-filter-button { + display: inline-flex; + align-items: center; + } + .software-filter-tooltip { + display: inline-flex; + align-items: center; + } + } } diff --git a/frontend/pages/hosts/ManageHostsPage/components/SoftwareVulnerabilities/SoftwareVulnerabilities.tsx b/frontend/pages/hosts/ManageHostsPage/components/SoftwareVulnerabilities/SoftwareVulnerabilities.tsx new file mode 100644 index 0000000000..e5bd805bc7 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/SoftwareVulnerabilities/SoftwareVulnerabilities.tsx @@ -0,0 +1,70 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from "react"; + +import { ISoftware } from "interfaces/software"; + +import CloseIcon from "../../../../../../assets/images/icon-close-fleet-black-16x16@2x.png"; +import ExternalLinkIcon from "../../../../../../assets/images/open-new-tab-12x12@2x.png"; +import IssueIcon from "../../../../../../assets/images/icon-issue-fleet-black-50-16x16@2x.png"; + +interface ISoftwareVulnerabilitiesProps { + software: ISoftware; +} + +const baseClass = "software-vulnerabilities"; + +const SoftwareVulnerabilities = ({ + software, +}: ISoftwareVulnerabilitiesProps): JSX.Element | null => { + const { name, version, vulnerabilities } = software; + const count = vulnerabilities?.length; + + const [showVulnerabilities, setShowVulnerablities] = useState(true); + + if (count && showVulnerabilities) { + return ( +
    +
    +
    + Software vulnerabilities found + {`${ + count === 1 ? "1 vulnerability" : `${count} vulnerabilities` + } detected ${name && version ? `for ${name}, ${version}` : ""}`} +
    +
    + +
    +
    +
    +
      + {vulnerabilities?.map((v) => { + return ( +
    • + Read more about {v.cve} vulnerability + + External link + +
    • + ); + })} +
    +
    +
    + ); + } + + return null; +}; +export default SoftwareVulnerabilities; diff --git a/frontend/pages/hosts/ManageHostsPage/components/SoftwareVulnerabilities/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/SoftwareVulnerabilities/_styles.scss new file mode 100644 index 0000000000..e36d4ffb69 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/SoftwareVulnerabilities/_styles.scss @@ -0,0 +1,82 @@ +.software-vulnerabilities { + font-size: $x-small; + background-color: $ui-off-white; + border: solid 1px $ui-fleet-black-50; + box-sizing: border-box; + border-radius: 10px; + overflow: auto; + margin-bottom: $pad-large; + padding: $pad-large; + padding-bottom: $pad-small; + + p { + padding-left: $pad-large; + } + + a { + color: $core-vibrant-blue; + font-weight: $bold; + text-decoration: none; + } + + img { + height: 16px; + width: auto; + padding-right: 10px; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + img { + display: inline-flex; + } + } + + &__count { + display: flex; + align-content: center; + align-items: center; + font-size: $small; + font-weight: $bold; + } + + &__ex { + text-decoration: none; + + button { + display: inline-flex; + align-items: center; + } + + img { + padding-right: 0; + } + } + + &__list { + b { + color: $core-vibrant-blue; + } + img { + height: 12px; + width: auto; + padding-left: $pad-small; + } + ul { + margin-top: 16px; + margin-bottom: 16px; + } + li { + margin-bottom: $pad-small; + } + } + + a { + img { + height: 12px; + width: auto; + } + } +} diff --git a/frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/index.js b/frontend/pages/hosts/ManageHostsPage/components/SoftwareVulnerabilities/index.ts similarity index 100% rename from frontend/pages/hosts/HostDetailsPage/SoftwareVulnerabilities/index.js rename to frontend/pages/hosts/ManageHostsPage/components/SoftwareVulnerabilities/index.ts diff --git a/frontend/services/entities/host_count.ts b/frontend/services/entities/host_count.ts index ab80a8e333..78217c9a46 100644 --- a/frontend/services/entities/host_count.ts +++ b/frontend/services/entities/host_count.ts @@ -16,6 +16,7 @@ export interface IHostCountLoadOptions { teamId?: number; policyId?: number; policyResponse?: string; + softwareId?: number; } export default { @@ -27,6 +28,7 @@ export default { const policyId = options?.policyId || null; const policyResponse = options?.policyResponse || null; const selectedLabels = options?.selectedLabels || []; + const softwareId = options?.softwareId || null; const labelPrefix = "labels/"; @@ -65,6 +67,11 @@ export default { queryString += `&policy_response=${policyResponse || "passing"}`; // TODO confirm whether there should be a default if there is an id but no response specified } + // TODO: consider how to check for mutually exclusive scenarios with label, policy and software + if (!label && !policyId && softwareId) { + queryString += `&software_id=${softwareId}`; + } + // Append query string to endpoint route after slicing off the leading ampersand const path = `${HOSTS_COUNT}${queryString && `?${queryString.slice(1)}`}`; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index 11edaca9c4..cc6919a218 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -17,6 +17,7 @@ export interface IHostLoadOptions { teamId?: number; policyId?: number; policyResponse?: string; + softwareId?: number; } export default { @@ -69,6 +70,7 @@ export default { const teamId = options?.teamId || null; const policyId = options?.policyId || null; const policyResponse = options?.policyResponse || null; + const softwareId = options?.softwareId || null; // TODO: add this query param logic to client class const pagination = `page=${page}&per_page=${perPage}`; @@ -120,7 +122,11 @@ export default { if (!label && policyId) { path += `&policy_id=${policyId}`; - path += `&policy_response=${policyResponse || "passing"}`; // TODO confirm whether there should be a default if there is an id but no response sepcified + path += `&policy_response=${policyResponse || "passing"}`; // TODO: confirm whether there should be a default if there is an id but no response sepcified + } + // TODO: consider how to check for mutually exclusive scenarios with label, policy and software + if (!label && !policyId && softwareId) { + path += `&software_id=${softwareId}`; } return sendRequest("GET", path);