Add new UI feature: Filter hosts by software version (#2541)

* 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
This commit is contained in:
gillespi314 2021-10-18 10:01:01 -05:00 committed by GitHub
parent b783b5721f
commit 8c6ca606c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 364 additions and 164 deletions

View File

@ -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

View File

@ -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 = ({
</div>
) : (
<>
<SoftwareVulnerabilities softwareList={host?.software} />
{host?.software && (
<SoftwareVulnerabilities softwareList={host?.software} />
)}
{host?.software && (
<TableContainer
columns={tableHeaders}
@ -811,8 +813,8 @@ const HostDetailsPage = ({
</div>
{renderLabels()}
{renderPacks()}
{renderUsers()}
{host?.software && renderSoftware()}
{renderUsers()}
{showDeleteHostModal && renderDeleteHostModal()}
{showQueryHostModal && (
<SelectQueryModal

View File

@ -1,16 +1,16 @@
import React from "react";
// import { Link } from "react-router"; // TODO: Enable after manage hosts page has been updated to filter hosts by software id
import { Link } from "react-router"; // TODO: Enable after manage hosts page has been updated to filter hosts by software id
import ReactTooltip from "react-tooltip";
import { isEmpty } from "lodash";
// import distanceInWordsToNow from "date-fns/distance_in_words_to_now"; // TODO: Enable after backend has been updated to provide last_opened_at
// import PATHS from "router/paths"; // TODO: Enable after manage hosts page has been updated to filter hosts by software id
import PATHS from "router/paths"; // TODO: Enable after manage hosts page has been updated to filter hosts by software id
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import { ISoftware } from "interfaces/software";
import IssueIcon from "../../../../../assets/images/icon-issue-fleet-black-50-16x16@2x.png";
import QuestionIcon from "../../../../../assets/images/icon-question-16x16@2x.png";
// import Chevron from "../../../../../assets/images/icon-chevron-blue-16x16@2x.png"; // TODO: Enable after manage hosts page has been updated to filter hosts by software id
import Chevron from "../../../../../assets/images/icon-chevron-blue-16x16@2x.png"; // TODO: Enable after manage hosts page has been updated to filter hosts by software id
interface IHeaderProps {
column: {
@ -195,20 +195,25 @@ const generateTableHeaders = (): IDataColumn[] => {
// 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={cellProps.cell.value} className={`software-link`}>
// <img alt="link to hosts filtered by software ID" src={Chevron} />
// </Link>
// );
// },
// disableHidden: true,
// },
{
title: "",
Header: "",
disableSortBy: true,
accessor: "linkToFilteredHosts",
Cell: (cellProps) => {
return (
<Link
to={`${
PATHS.MANAGE_HOSTS
}?software_id=${cellProps.row.original.id.toString()}`}
className={`software-link`}
>
<img alt="link to hosts filtered by software ID" src={Chevron} />
</Link>
);
},
disableHidden: true,
},
];
};

View File

@ -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";

View File

@ -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 {

View File

@ -0,0 +1 @@
export { default } from "./SoftwareVulnCount";

View File

@ -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 (
<li key={index}>
Read more about <b>{vul.name}</b>{" "}
<a href={vul.details_link} target="_blank" rel="noopener noreferrer">
{vul.cve} vulnerability &nbsp;
<img alt="External link" src={FleetIcon} />
</a>
</li>
);
};
// No software vulnerabilities
if (vulsList.length === 0) {
return null;
}
// Software vulnerabilities
return (
<div className={`${baseClass}`}>
<div className={`${baseClass}__count`}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="19"
height="19"
viewBox="0 -2.5 19 19"
fill="none"
>
<path
d="M0 8C0 12.4183 3.5817 16 8 16C12.4183 16 16 12.4183 16 8C16 3.5817 12.4183 0 8 0C3.5817 0 0 3.5817 0 8ZM14 8C14 11.3137 11.3137 14 8 14C4.6863 14 2 11.3137 2 8C2 4.6863 4.6863 2 8 2C11.3137 2 14 4.6863 14 8ZM7 12V10H9V12H7ZM7 4V9H9V4H7Z"
fill="#8B8FA2"
/>
</svg>
&nbsp;
{renderVulsCount(vulsList)}
</div>
<div className={`${baseClass}__list`}>
<ul>{vulsList.map((vul, index) => renderVul(vul, index))}</ul>
</div>
</div>
);
}
}
export default SoftwareVulnerabilities;

View File

@ -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 {

View File

@ -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<boolean>(false);
const [sortBy, setSortBy] = useState<ISortOption[]>(initialSortBy);
const [policy, setPolicy] = useState<IPolicy>();
const [softwareDetails, setSoftwareDetails] = useState<ISoftware | null>(
null
);
const [tableQueryData, setTableQueryData] = useState<ITableQueryProps>();
// ======== 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 (
<div className={`${baseClass}__software-filter-block`}>
<Button
className={`${baseClass}__clear-software-filter`}
onClick={handleClearSoftwareFilter}
variant={"small-text-icon"}
title={name}
>
<span className="software-filter-button">
<span
className="software-filter-tooltip"
data-tip
data-for="software-filter-tooltip"
data-tip-disable={!name || !version}
>
{buttonText}
<img src={CloseIcon} alt="Remove software filter" />
</span>
<ReactTooltip
place="bottom"
type="dark"
effect="solid"
backgroundColor="#3e4771"
id="software-filter-tooltip"
data-html
>
<span className={`tooltip__tooltip-text`}>
{`Hosts with ${name}`},<br />
{`${version} installed`}
</span>
</ReactTooltip>
</span>
</Button>
</div>
);
}
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 (
<div className={`${baseClass}__label-block`}>
<div className="title">
<span>{displayText}</span>
{labelType !== "builtin" && !isOnlyObserver && (
<>
<Button onClick={onEditLabelClick} variant={"text-icon"}>
<img src={PencilIcon} alt="Edit label" />
</Button>
<Button onClick={toggleDeleteLabelModal} variant={"text-icon"}>
<img src={TrashIcon} alt="Delete label" />
</Button>
</>
)}
return (
<div className={`${baseClass}__label-block`}>
<div className="title">
<span>
{PLATFORM_LABEL_DISPLAY_NAMES[displayText] || displayText}
</span>
{labelType !== "builtin" && !isOnlyObserver && (
<>
<Button onClick={onEditLabelClick} variant={"text-icon"}>
<img src={PencilIcon} alt="Edit label" />
</Button>
<Button onClick={toggleDeleteLabelModal} variant={"text-icon"}>
<img src={TrashIcon} alt="Delete label" />
</Button>
</>
)}
</div>
<div className="description">
<span>{description}</span>
</div>
</div>
<div className="description">
<span>{description}</span>
</div>
</div>
);
);
}
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 (
<div className={`${baseClass}__labels-policies-wrap`}>
{policyId && renderPoliciesFilterBlock()}
{!policyId &&
type !== "all" &&
type !== "status" &&
selectedLabel &&
renderHeaderLabelBlock(selectedLabel)}
<div className={`${baseClass}__labels-active-filter-wrap`}>
{showSelectedLabel && renderHeaderLabelBlock()}
{!!policyId &&
!softwareId &&
!showSelectedLabel &&
renderPoliciesFilterBlock()}
{!!softwareId &&
!policyId &&
!showSelectedLabel &&
renderSoftwareFilterBlock()}
</div>
);
}
return null;
};
const renderSoftwareVulnerabilities = () => {
if (softwareDetails) {
return <SoftwareVulnerabilities software={softwareDetails} />;
}
return null;
};
const renderForm = () => {
if (isAddLabel) {
return (
@ -1261,7 +1361,8 @@ const ManageHostsPage = ({
)}
</div>
</div>
{renderLabelOrPolicyBlock()}
{renderActiveFilterBlock()}
{renderSoftwareVulnerabilities()}
{config && (!isPremiumTier || teams) && renderTable(selectedTeam)}
</div>
)}

View File

@ -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;
}
}
}

View File

@ -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<boolean>(true);
if (count && showVulnerabilities) {
return (
<div className={`${baseClass}`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__count`}>
<img alt="Software vulnerabilities found" src={IssueIcon} />
{`${
count === 1 ? "1 vulnerability" : `${count} vulnerabilities`
} detected ${name && version ? `for ${name}, ${version}` : ""}`}
</div>
<div className={`${baseClass}__ex`}>
<button
className="button button--unstyled"
onClick={() => setShowVulnerablities(!showVulnerabilities)}
>
<img
alt="Dismiss software vulnerabilities banner"
src={CloseIcon}
/>
</button>
</div>
</div>
<div className={`${baseClass}__list`}>
<ul>
{vulnerabilities?.map((v) => {
return (
<li key={v.cve}>
Read more about <b>{v.cve} vulnerability</b>
<a
href={v.details_link}
target="_blank"
rel="noopener noreferrer"
>
<img alt="External link" src={ExternalLinkIcon} />
</a>
</li>
);
})}
</ul>
</div>
</div>
);
}
return null;
};
export default SoftwareVulnerabilities;

View File

@ -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;
}
}
}

View File

@ -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)}`}`;

View File

@ -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);