mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Add policies table to host details page (#2547)
This commit is contained in:
parent
fda757a700
commit
fb6d83ea05
16 changed files with 362 additions and 11 deletions
BIN
assets/images/icon-chevron-right-9x6@2x.png
Normal file
BIN
assets/images/icon-chevron-right-9x6@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1,015 B |
|
|
@ -1 +1,2 @@
|
|||
* Add host policies to the host details API.
|
||||
* Add host policies table to host details page.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
15
frontend/interfaces/host_policy.ts
Normal file
15
frontend/interfaces/host_policy.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.policy-details-modal {
|
||||
&__btn-wrap {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: $pad-xxlarge;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PolicyDetailsModal";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PolicyFailingCount";
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ export const isAcceptableStatus = (filter: string) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const isValidPolicyResponse = (filter: string) => {
|
||||
return filter === "pass" || filter === "fail";
|
||||
};
|
||||
|
||||
export const getNextLocationPath = ({
|
||||
pathPrefix = "",
|
||||
routeTemplate = "",
|
||||
|
|
|
|||
Loading…
Reference in a new issue