Add policies table to host details page (#2547)

This commit is contained in:
Luke Heath 2021-10-18 13:29:48 -05:00 committed by GitHub
parent fda757a700
commit fb6d83ea05
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 362 additions and 11 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,015 B

View file

@ -1 +1,2 @@
* Add host policies to the host details API.
* Add host policies table to host details page.

View file

@ -29,7 +29,7 @@ describe(
cy.contains("button", /generate installer/i).click();
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(2000);
cy.wait(1000);
cy.findByText(/rpm/i).click();
cy.contains("a", /download/i)
.first()
@ -51,13 +51,13 @@ describe(
cy.visit("/hosts/manage");
cy.location("pathname").should("match", /hosts\/manage/i);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(3000);
cy.wait(1000);
cy.get('button[title="Online"]').click();
// Go to host details page
cy.location("pathname").should("match", /hosts\/[0-9]/i);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(3000);
cy.wait(1000);
cy.get("span.status").contains(/online/i);
}
);

View file

@ -1,4 +1,5 @@
import PropTypes from "prop-types";
import hostPolicyInterface, { IHostPolicy } from "./host_policy";
import hostUserInterface, { IHostUser } from "./host_users";
import labelInterface, { ILabel } from "./label";
import packInterface, { IPack } from "./pack";
@ -56,6 +57,7 @@ export default PropTypes.shape({
status: PropTypes.string,
display_text: PropTypes.string,
users: PropTypes.arrayOf(hostUserInterface),
policies: PropTypes.arrayOf(hostPolicyInterface),
});
export interface IDeviceUser {
@ -126,4 +128,5 @@ export interface IHost {
device_users?: IDeviceUser[];
munki?: IMunkiData;
mdm?: IMDMData;
policies: IHostPolicy[];
}

View file

@ -0,0 +1,15 @@
import PropTypes from "prop-types";
export default PropTypes.shape({
id: PropTypes.number,
query_id: PropTypes.number,
query_name: PropTypes.string,
response: PropTypes.string,
});
export interface IHostPolicy {
id: number;
query_id: number;
query_name: string;
response: string;
}

View file

@ -1,4 +1,4 @@
import React, { useContext, useState } from "react";
import React, { useCallback, useContext, useState } from "react";
import { useDispatch } from "react-redux";
import { Link } from "react-router";
import { Params } from "react-router/lib/Router";
@ -13,6 +13,7 @@ import teamAPI from "services/entities/teams";
import { AppContext } from "context/app";
import { IHost } from "interfaces/host";
import { ISoftware } from "interfaces/software";
import { IHostPolicy } from "interfaces/host_policy";
import { ILabel } from "interfaces/label";
import { ITeam } from "interfaces/team";
import { IQuery } from "interfaces/query";
@ -27,6 +28,7 @@ import Modal from "components/modals/Modal"; // @ts-ignore
import SoftwareVulnerabilities from "pages/hosts/HostDetailsPage/SoftwareVulnCount"; // @ts-ignore
import HostUsersListRow from "pages/hosts/HostDetailsPage/HostUsersListRow";
import TableContainer from "components/TableContainer";
import InfoBanner from "components/InfoBanner";
import {
Accordion,
@ -46,15 +48,22 @@ import {
} from "fleet/helpers"; // @ts-ignore
import SelectQueryModal from "./SelectQueryModal";
import TransferHostModal from "./TransferHostModal";
import PolicyDetailsModal from "./HostPoliciesTable/PolicyDetailsModal";
import {
generateTableHeaders,
generateDataSet,
generatePolicyTableHeaders,
generatePolicyDataSet,
} from "./HostPoliciesTable/HostPoliciesTableConfig";
import {
generateSoftwareTableHeaders,
generateSoftwareDataSet,
} from "./SoftwareTable/SoftwareTableConfig";
import {
generatePackTableHeaders,
generatePackDataSet,
} from "./PackTable/PackTableConfig";
import EmptySoftware from "./EmptySoftware";
import PolicyFailingCount from "./HostPoliciesTable/PolicyFailingCount";
import { isValidPolicyResponse } from "../ManageHostsPage/helpers";
import BackChevron from "../../../../assets/images/icon-chevron-down-9x6@2x.png";
import DeleteIcon from "../../../../assets/images/icon-action-delete-14x14@2x.png";
@ -102,6 +111,12 @@ const HostDetailsPage = ({
false
);
const [showQueryHostModal, setShowQueryHostModal] = useState<boolean>(false);
const [showPolicyDetailsModal, setPolicyDetailsModal] = useState(false);
const togglePolicyDetailsModal = useCallback(() => {
setPolicyDetailsModal(!showPolicyDetailsModal);
}, [showPolicyDetailsModal, setPolicyDetailsModal]);
const [
showRefetchLoadingSpinner,
setShowRefetchLoadingSpinner,
@ -465,6 +480,59 @@ const HostDetailsPage = ({
);
};
const renderPolicies = () => {
const tableHeaders = generatePolicyTableHeaders(togglePolicyDetailsModal);
const noResponses: IHostPolicy[] =
host?.policies.filter(
(policy) => !isValidPolicyResponse(policy.response)
) || [];
const failingResponses: IHostPolicy[] =
host?.policies.filter((policy) => policy.response === "fail") || [];
return (
<div className="section section--policies">
<p className="section__header">Policies</p>
{host?.policies.length && (
<>
{failingResponses.length > 0 && (
<PolicyFailingCount policyList={host?.policies} />
)}
{noResponses.length > 0 && (
<InfoBanner>
<p>
This host is not updating the response for some policies.
Check&nbsp;
<a
href="https://fleetdm.com/docs/using-fleet/faq#why-my-host-is-not-updating-a-policys-response"
target="_blank"
rel="noopener noreferrer"
>
out the Fleet documentation on why the response might not be
updating.
</a>
</p>
</InfoBanner>
)}
<TableContainer
columns={tableHeaders}
data={generatePolicyDataSet(host.policies)}
isLoading={isLoadingHost}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
resultsTitle={"policy items"}
emptyComponent={() => <></>}
showMarkAllPages={false}
isAllPagesSelected={false}
disablePagination
disableCount
/>
</>
)}
</div>
);
};
const renderUsers = () => {
const { users } = host || {};
const wrapperClassName = `${baseClass}__table`;
@ -504,7 +572,7 @@ const HostDetailsPage = ({
};
const renderSoftware = () => {
const tableHeaders = generateTableHeaders();
const tableHeaders = generateSoftwareTableHeaders();
return (
<div className="section section--software">
@ -528,7 +596,7 @@ const HostDetailsPage = ({
{host?.software && (
<TableContainer
columns={tableHeaders}
data={generateDataSet(softwareState)}
data={generateSoftwareDataSet(softwareState)}
isLoading={isLoadingHost}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
@ -788,6 +856,7 @@ const HostDetailsPage = ({
{renderMDMData()}
</div>
</div>
{host?.policies && renderPolicies()}
<div className="section osquery col-50">
<p className="section__header">Agent options</p>
<div className="info-grid">
@ -834,6 +903,9 @@ const HostDetailsPage = ({
isGlobalAdmin={isGlobalAdmin as boolean}
/>
)}
{!!host && showPolicyDetailsModal && (
<PolicyDetailsModal onCancel={togglePolicyDetailsModal} />
)}
</div>
);
};

View file

@ -0,0 +1,117 @@
import React from "react";
import { Link } from "react-router";
import PATHS from "router/paths";
import TextCell from "components/TableContainer/DataTable/TextCell";
import Button from "components/buttons/Button";
import { IHostPolicy } from "interfaces/host_policy";
import { PolicyResponse } from "utilities/constants";
import Chevron from "../../../../../assets/images/icon-chevron-right-9x6@2x.png";
import ArrowIcon from "../../../../../assets/images/icon-arrow-right-vibrant-blue-10x18@2x.png";
const TAGGED_TEMPLATES = {
hostsByPolicyRoute: (policyId: number, policyResponse: PolicyResponse) => {
return `?policy_id=${policyId}&policy_response=${policyResponse}`;
},
};
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
}
interface ICellProps {
cell: {
value: any;
};
row: {
original: IHostPolicy;
};
}
interface IDataColumn {
title: string;
Header: ((props: IHeaderProps) => JSX.Element) | string;
accessor: string;
Cell: (props: ICellProps) => JSX.Element;
disableHidden?: boolean;
disableSortBy?: boolean;
sortType?: string;
}
const getPolicyStatus = (policy: IHostPolicy): string => {
if (policy.response === "pass") {
return "Passing";
} else if (policy.response === "fail") {
return "Failing";
}
return "---";
};
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generatePolicyTableHeaders = (
togglePolicyDetails: () => void
): IDataColumn[] => {
return [
{
title: "Name",
Header: "Name",
accessor: "name",
disableSortBy: true,
Cell: (cellProps) => {
const { query_name } = cellProps.row.original;
return (
<>
<Button onClick={togglePolicyDetails} variant={"text-icon"}>
<>
{query_name}
<img src={ArrowIcon} alt="View policy details" />
</>
</Button>
</>
);
},
},
{
title: "Status",
Header: "Status",
accessor: "response",
disableSortBy: true,
Cell: (cellProps) => {
return <TextCell value={getPolicyStatus(cellProps.row.original)} />;
},
},
{
title: "",
Header: "",
accessor: "linkToFilteredHosts",
disableSortBy: true,
Cell: (cellProps) => {
return (
<Link
to={
PATHS.MANAGE_HOSTS +
TAGGED_TEMPLATES.hostsByPolicyRoute(
cellProps.row.original.id,
cellProps.row.original.response === "pass"
? PolicyResponse.PASSING
: PolicyResponse.FAILING
)
}
className={`policy-link`}
>
<img alt="link to hosts filtered by policy ID" src={Chevron} />
</Link>
);
},
},
];
};
const generatePolicyDataSet = (policies: IHostPolicy[]): IHostPolicy[] => {
return policies;
};
export { generatePolicyTableHeaders, generatePolicyDataSet };

View file

@ -0,0 +1,38 @@
import React from "react";
import Button from "components/buttons/Button";
import Modal from "components/modals/Modal";
interface IPolicyDetailsProps {
onCancel: () => void;
}
const baseClass = "policy-details-modal";
const PolicyDetailsModal = (props: IPolicyDetailsProps): JSX.Element => {
const { onCancel } = props;
return (
<Modal title={"Policy Name"} onExit={onCancel} className={baseClass}>
<div className={`${baseClass}__modal-body`}>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas
feugiat venenatis quam, nec eleifend nisi aliquet non. Sed feugiat
rutrum turpis, ac convallis odio egestas sit amet. Fusce vel sem
massa. Quisque porttitor metus id vulputate vehicula. Donec ut nunc
tempor, pretium lorem et, tempus est.
</p>
<div className={`${baseClass}__btn-wrap`}>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
variant="brand"
>
Cancel
</Button>
</div>
</div>
</Modal>
);
};
export default PolicyDetailsModal;

View file

@ -0,0 +1,11 @@
.policy-details-modal {
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
margin-top: $pad-xxlarge;
}
&__btn {
margin-left: 12px;
}
}

View file

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

View file

@ -0,0 +1,32 @@
import { IHostPolicy } from "interfaces/host_policy";
import React from "react";
import IssueIcon from "../../../../../../assets/images/icon-issue-fleet-black-50-16x16@2x.png";
const baseClass = "policy-failing-count";
const PolicyFailingCount = (policyProps: {
policyList: IHostPolicy[];
}): JSX.Element | null => {
const { policyList } = policyProps;
const failCount = policyList.reduce((sum, policy) => {
return policy.response === "fail" ? sum + 1 : sum;
}, 0);
return failCount ? (
<div className={`${baseClass}`}>
<div className={`${baseClass}__count`}>
<img alt="Issue icon" src={IssueIcon} />
This device is failing
{failCount === 1 ? " 1 policy" : ` ${failCount} policies`}
</div>
<p>
Click a policy below to see steps for resolving the failure
{failCount > 1 ? "s" : ""}.
</p>
</div>
) : null;
};
export default PolicyFailingCount;

View file

@ -0,0 +1,31 @@
.policy-failing-count {
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;
margin-top: $pad-medium;
margin-bottom: $pad-medium;
}
img {
height: 16px;
width: auto;
padding-right: 10px;
}
&__count {
display: flex;
align-content: center;
align-items: center;
font-size: $small;
font-weight: $bold;
}
}

View file

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

View file

@ -63,7 +63,7 @@ const TYPE_CONVERSION: Record<string, string> = {
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = (): IDataColumn[] => {
const generateSoftwareTableHeaders = (): IDataColumn[] => {
return [
{
title: "Vulnerabilities",
@ -227,7 +227,9 @@ const enhanceSoftwareData = (software: ISoftware[]): ISoftwareTableData[] => {
});
};
const generateDataSet = (software: ISoftware[]): ISoftwareTableData[] => {
const generateSoftwareDataSet = (
software: ISoftware[]
): ISoftwareTableData[] => {
// Cannot pass undefined to enhanceSoftwareData
if (!software) {
return software;
@ -236,4 +238,4 @@ const generateDataSet = (software: ISoftware[]): ISoftwareTableData[] => {
return [...enhanceSoftwareData(software)];
};
export { generateTableHeaders, generateDataSet };
export { generateSoftwareTableHeaders, generateSoftwareDataSet };

View file

@ -373,6 +373,29 @@
}
}
.section--policies {
.linkToFilteredHosts__header {
width: 0;
}
.policy-link img {
width: 16px;
}
.table-container__header {
display: none;
}
.info-banner {
margin-bottom: 1rem;
p {
font-size: 0.875rem;
margin: 0;
}
a {
text-decoration: none;
font-weight: bold;
}
}
}
.section--software {
th {
&:first-child {

View file

@ -69,6 +69,10 @@ export const isAcceptableStatus = (filter: string) => {
);
};
export const isValidPolicyResponse = (filter: string) => {
return filter === "pass" || filter === "fail";
};
export const getNextLocationPath = ({
pathPrefix = "",
routeTemplate = "",