diff --git a/assets/images/icon-chevron-right-9x6@2x.png b/assets/images/icon-chevron-right-9x6@2x.png new file mode 100644 index 0000000000..8c8e26e9ed Binary files /dev/null and b/assets/images/icon-chevron-right-9x6@2x.png differ diff --git a/changes/issue-1890-add-host-policies-to-details b/changes/issue-1890-add-host-policies-to-details index cc6e081a79..374de38f8a 100644 --- a/changes/issue-1890-add-host-policies-to-details +++ b/changes/issue-1890-add-host-policies-to-details @@ -1 +1,2 @@ * Add host policies to the host details API. +* Add host policies table to host details page. diff --git a/cypress/integration/all/app/hosts.spec.ts b/cypress/integration/all/app/hosts.spec.ts index 35b48e2cbb..cf214a9922 100644 --- a/cypress/integration/all/app/hosts.spec.ts +++ b/cypress/integration/all/app/hosts.spec.ts @@ -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); } ); diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index fada84f846..b7f0834033 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -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[]; } diff --git a/frontend/interfaces/host_policy.ts b/frontend/interfaces/host_policy.ts new file mode 100644 index 0000000000..57ddebbad8 --- /dev/null +++ b/frontend/interfaces/host_policy.ts @@ -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; +} diff --git a/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx index 6a09b647fe..bb4c9c7a11 100644 --- a/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx @@ -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(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 ( +
+

Policies

+ + {host?.policies.length && ( + <> + {failingResponses.length > 0 && ( + + )} + {noResponses.length > 0 && ( + +

+ This host is not updating the response for some policies. + Check  + + out the Fleet documentation on why the response might not be + updating. + +

+
+ )} + <>} + showMarkAllPages={false} + isAllPagesSelected={false} + disablePagination + disableCount + /> + + )} +
+ ); + }; + const renderUsers = () => { const { users } = host || {}; const wrapperClassName = `${baseClass}__table`; @@ -504,7 +572,7 @@ const HostDetailsPage = ({ }; const renderSoftware = () => { - const tableHeaders = generateTableHeaders(); + const tableHeaders = generateSoftwareTableHeaders(); return (
@@ -528,7 +596,7 @@ const HostDetailsPage = ({ {host?.software && (
+ {host?.policies && renderPolicies()}

Agent options

@@ -834,6 +903,9 @@ const HostDetailsPage = ({ isGlobalAdmin={isGlobalAdmin as boolean} /> )} + {!!host && showPolicyDetailsModal && ( + + )}
); }; diff --git a/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/HostPoliciesTableConfig.tsx b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/HostPoliciesTableConfig.tsx new file mode 100644 index 0000000000..25463555d1 --- /dev/null +++ b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/HostPoliciesTableConfig.tsx @@ -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 ( + <> + + + ); + }, + }, + { + title: "Status", + Header: "Status", + accessor: "response", + disableSortBy: true, + Cell: (cellProps) => { + return ; + }, + }, + { + title: "", + Header: "", + accessor: "linkToFilteredHosts", + disableSortBy: true, + Cell: (cellProps) => { + return ( + + link to hosts filtered by policy ID + + ); + }, + }, + ]; +}; + +const generatePolicyDataSet = (policies: IHostPolicy[]): IHostPolicy[] => { + return policies; +}; + +export { generatePolicyTableHeaders, generatePolicyDataSet }; diff --git a/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/PolicyDetailsModal.tsx b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/PolicyDetailsModal.tsx new file mode 100644 index 0000000000..8769f13365 --- /dev/null +++ b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/PolicyDetailsModal.tsx @@ -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 ( + +
+

+ 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. +

+
+ +
+
+
+ ); +}; + +export default PolicyDetailsModal; diff --git a/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/_styles.scss b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/_styles.scss new file mode 100644 index 0000000000..480f21b1a8 --- /dev/null +++ b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/_styles.scss @@ -0,0 +1,11 @@ +.policy-details-modal { + &__btn-wrap { + display: flex; + flex-direction: row-reverse; + margin-top: $pad-xxlarge; + } + + &__btn { + margin-left: 12px; + } +} diff --git a/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/index.ts b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/index.ts new file mode 100644 index 0000000000..1b243b6c9f --- /dev/null +++ b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./PolicyDetailsModal"; diff --git a/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx new file mode 100644 index 0000000000..e94673a3c4 --- /dev/null +++ b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx @@ -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 ? ( +
+
+ Issue icon + This device is failing + {failCount === 1 ? " 1 policy" : ` ${failCount} policies`} +
+

+ Click a policy below to see steps for resolving the failure + {failCount > 1 ? "s" : ""}. +

+
+ ) : null; +}; + +export default PolicyFailingCount; diff --git a/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/_styles.scss b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/_styles.scss new file mode 100644 index 0000000000..6777bd9001 --- /dev/null +++ b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/_styles.scss @@ -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; + } +} diff --git a/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/index.ts b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/index.ts new file mode 100644 index 0000000000..53f91b4c78 --- /dev/null +++ b/frontend/pages/hosts/HostDetailsPage/HostPoliciesTable/PolicyFailingCount/index.ts @@ -0,0 +1 @@ +export { default } from "./PolicyFailingCount"; diff --git a/frontend/pages/hosts/HostDetailsPage/SoftwareTable/SoftwareTableConfig.tsx b/frontend/pages/hosts/HostDetailsPage/SoftwareTable/SoftwareTableConfig.tsx index a599b7f629..42cbc54a84 100644 --- a/frontend/pages/hosts/HostDetailsPage/SoftwareTable/SoftwareTableConfig.tsx +++ b/frontend/pages/hosts/HostDetailsPage/SoftwareTable/SoftwareTableConfig.tsx @@ -63,7 +63,7 @@ const TYPE_CONVERSION: Record = { // 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 }; diff --git a/frontend/pages/hosts/HostDetailsPage/_styles.scss b/frontend/pages/hosts/HostDetailsPage/_styles.scss index 7092ce441d..0476d2b930 100644 --- a/frontend/pages/hosts/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/HostDetailsPage/_styles.scss @@ -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 { diff --git a/frontend/pages/hosts/ManageHostsPage/helpers.ts b/frontend/pages/hosts/ManageHostsPage/helpers.ts index 77f0089e79..7cd69ced62 100644 --- a/frontend/pages/hosts/ManageHostsPage/helpers.ts +++ b/frontend/pages/hosts/ManageHostsPage/helpers.ts @@ -69,6 +69,10 @@ export const isAcceptableStatus = (filter: string) => { ); }; +export const isValidPolicyResponse = (filter: string) => { + return filter === "pass" || filter === "fail"; +}; + export const getNextLocationPath = ({ pathPrefix = "", routeTemplate = "",