Enhanced UI for host operating systems to include additional information for Windows and macOS (#7201)

This commit is contained in:
gillespi314 2022-08-15 16:39:00 -05:00 committed by GitHub
parent 7c793fdc7d
commit 8d4ad6ce9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 353 additions and 112 deletions

View file

@ -2,6 +2,7 @@
kernel version.
- Updated `GET /os_versions` endpoint to include new request and response fields.
- Updated `GET /hosts` endpoint to add `operating_system_id` query parameter.
- Enhanced UI for host operating systems to include additional information for Windows and macOS.
Note that the operating systems data and the aggregated stats are updated lazily to prevent a long
database migration when upgrading to this Fleet version - the data will be updated as hosts send

View file

@ -20,5 +20,12 @@ describe("Dashboard", () => {
});
cy.getAttached(".operating-systems").should("exist");
});
it("displays operating systems card if Windows platform is selected", () => {
cy.getAttached(".homepage__platform_dropdown").click();
cy.getAttached(".Select-menu-outer").within(() => {
cy.findAllByText("Windows").click();
});
cy.getAttached(".operating-systems").should("exist");
});
});
});

View file

@ -1,5 +1,18 @@
export interface IOperatingSystemVersion {
id: number;
os_id: number;
name: string;
name_only: string;
version: string;
platform: string;
hosts_count: number;
}
export const OS_VENDOR_BY_PLATFORM: Record<string, string> = {
darwin: "Apple",
windows: "Microsoft",
} as const;
export const OS_END_OF_LIFE_LINK_BY_PLATFORM: Record<string, string> = {
darwin: "https://endoflife.date/macos",
windows: "https://endoflife.date/windows",
} as const;

View file

@ -304,8 +304,8 @@ const Homepage = (): JSX.Element => {
<OperatingSystems
currentTeamId={currentTeam?.id}
selectedPlatform={selectedPlatform as IOsqueryPlatform}
setShowOperatingSystemsUI={setShowOperatingSystemsUI}
showOperatingSystemsUI={showOperatingSystemsUI}
showTitle={showOperatingSystemsUI}
setShowTitle={setShowOperatingSystemsUI}
/>
),
});
@ -336,7 +336,9 @@ const Homepage = (): JSX.Element => {
</div>
);
const windowsLayout = () => null;
const windowsLayout = () => (
<div className={`${baseClass}__section`}>{OperatingSystemsCard}</div>
);
const linuxLayout = () => null;
const renderCards = () => {

View file

@ -1,9 +1,16 @@
import React from "react";
import React, { useEffect } from "react";
import { useQuery } from "react-query";
import {
OS_END_OF_LIFE_LINK_BY_PLATFORM,
OS_VENDOR_BY_PLATFORM,
} from "interfaces/operating_system";
import { IOsqueryPlatform } from "interfaces/platform";
import operatingSystemsAPI, {
IOperatingSystemsResponse,
import {
getOSVersions,
IGetOSVersionsQueryKey,
IOSVersionsResponse,
OS_VERSIONS_API_SUPPORTED_PLATFORMS,
} from "services/entities/operating_systems";
import { PLATFORM_DISPLAY_NAMES } from "utilities/constants";
@ -12,19 +19,19 @@ import Spinner from "components/Spinner";
import TableDataError from "components/DataError";
import LastUpdatedText from "components/LastUpdatedText";
import ExternalURLIcon from "../../../../../assets/images/icon-external-url-12x12@2x.png";
import generateTableHeaders from "./OperatingSystemsTableConfig";
interface IOperatingSystemsCardProps {
currentTeamId: number | undefined;
selectedPlatform: IOsqueryPlatform;
showOperatingSystemsUI: boolean;
setShowOperatingSystemsUI: (showOperatingSystemsTitle: boolean) => void;
showTitle: boolean;
setShowTitle: (showTitle: boolean) => void;
setTitleDetail?: (content: JSX.Element | string | null) => void;
setTitleDescription?: (content: JSX.Element | string | null) => void;
}
// TODO: add platforms to this constant as new ones are supported
const OS_API_SUPPORTED_PLATFORMS: IOsqueryPlatform[] = ["darwin"];
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_SORT_HEADER = "hosts_count";
const PAGE_SIZE = 8;
@ -32,9 +39,9 @@ const baseClass = "operating-systems";
const EmptyOperatingSystems = (platform: IOsqueryPlatform): JSX.Element => (
<div className={`${baseClass}__empty-os`}>
<h1>{`No ${
PLATFORM_DISPLAY_NAMES[platform] || "supported"
} operating systems detected`}</h1>
<h1>{`No${
` ${PLATFORM_DISPLAY_NAMES[platform]}` || ""
} operating systems detected.`}</h1>
<p>
{`Did you add ${`${PLATFORM_DISPLAY_NAMES[platform]} ` || ""}hosts to
Fleet? Try again in about an hour as the system catches up.`}
@ -45,66 +52,92 @@ const EmptyOperatingSystems = (platform: IOsqueryPlatform): JSX.Element => (
const OperatingSystems = ({
currentTeamId,
selectedPlatform,
showOperatingSystemsUI,
setShowOperatingSystemsUI,
showTitle,
setShowTitle,
setTitleDetail,
setTitleDescription,
}: IOperatingSystemsCardProps): JSX.Element => {
const { data: osInfo, error: errorOS, isFetching } = useQuery<
IOperatingSystemsResponse,
const { data: osInfo, error, isFetching } = useQuery<
IOSVersionsResponse,
Error,
IOperatingSystemsResponse,
Array<{
scope: string;
platform: IOsqueryPlatform;
teamId: number | undefined;
}>
IOSVersionsResponse,
IGetOSVersionsQueryKey[]
>(
[
{
scope: "os_version",
scope: "os_versions",
platform: selectedPlatform,
teamId: currentTeamId,
},
],
({ queryKey: [{ platform, teamId }] }) => {
return operatingSystemsAPI.getVersions({
return getOSVersions({
platform,
teamId,
});
},
{
enabled: OS_API_SUPPORTED_PLATFORMS.includes(selectedPlatform),
enabled: OS_VERSIONS_API_SUPPORTED_PLATFORMS.includes(selectedPlatform),
staleTime: 10000,
keepPreviousData: true,
onSuccess: (data) => {
setShowOperatingSystemsUI(true);
setTitleDetail &&
setTitleDetail(
<LastUpdatedText
lastUpdatedAt={data.counts_updated_at}
whatToRetrieve={"operating systems"}
/>
);
},
onError: () => {
setShowOperatingSystemsUI(true);
},
}
);
const description =
OS_VENDOR_BY_PLATFORM[selectedPlatform] &&
OS_END_OF_LIFE_LINK_BY_PLATFORM[selectedPlatform] ? (
<p>
{OS_VENDOR_BY_PLATFORM[selectedPlatform]} releases updates and fixes for
supported operating systems.{" "}
<a
target="_blank"
rel="noreferrer noopener"
href={OS_END_OF_LIFE_LINK_BY_PLATFORM[selectedPlatform]}
>
See supported operating systems <img src={ExternalURLIcon} alt="" />
</a>
</p>
) : null;
const titleDetail = osInfo?.counts_updated_at ? (
<LastUpdatedText
lastUpdatedAt={osInfo?.counts_updated_at}
whatToRetrieve={"operating systems"}
/>
) : null;
useEffect(() => {
if (isFetching) {
setShowTitle(false);
setTitleDescription?.(null);
setTitleDetail?.(null);
return;
}
setShowTitle(true);
if (osInfo?.os_versions?.length) {
setTitleDescription?.(description);
setTitleDetail?.(titleDetail);
return;
}
setTitleDescription?.(null);
setTitleDetail?.(null);
}, [isFetching, osInfo, setTitleDescription, setTitleDetail]);
const tableHeaders = generateTableHeaders();
const showPaginationControls = (osInfo?.os_versions?.length || 0) > 8;
// Renders opaque information as host information is loading
const opacity = showOperatingSystemsUI ? { opacity: 1 } : { opacity: 0 };
const opacity = isFetching || !showTitle ? { opacity: 0 } : { opacity: 1 };
return (
<div className={baseClass}>
{!showOperatingSystemsUI && !errorOS && (
{isFetching && (
<div className="spinner">
<Spinner />
</div>
)}
<div style={opacity}>
{errorOS ? (
{error ? (
<TableDataError card />
) : (
<TableContainer
@ -120,8 +153,10 @@ const OperatingSystems = ({
isAllPagesSelected={false}
disableCount
disableActionButton
isClientSidePagination
isClientSidePagination={showPaginationControls}
disablePagination={!showPaginationControls}
pageSize={PAGE_SIZE}
highlightOnHover
/>
)}
</div>

View file

@ -1,12 +1,14 @@
import React from "react";
import { Link } from "react-router";
import PATHS from "router/paths";
import { IOperatingSystemVersion } from "interfaces/operating_system";
import TextCell from "components/TableContainer/DataTable/TextCell";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
import Chevron from "../../../../../assets/images/icon-chevron-right-blue-16x16@2x.png";
interface ICellProps {
cell: {
value: string;
@ -32,12 +34,19 @@ interface IDataColumn {
disableSortBy?: boolean;
}
const osTableHeaders = [
const defaultTableHeaders = [
{
title: "Name",
Header: "Name",
disableSortBy: true,
accessor: "name",
accessor: "name_only",
Cell: (cellProps: ICellProps) => <TextCell value={cellProps.cell.value} />,
},
{
title: "Version",
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: (cellProps: ICellProps) => <TextCell value={cellProps.cell.value} />,
},
{
@ -50,12 +59,32 @@ const osTableHeaders = [
),
disableSortBy: false,
accessor: "hosts_count",
Cell: (cellProps: ICellProps) => <TextCell value={cellProps.cell.value} />,
Cell: (cellProps: ICellProps): JSX.Element => {
return (
<span className="hosts-cell__wrapper">
<span className="hosts-cell__count">
<TextCell value={cellProps.cell.value} />
</span>
<span className="hosts-cell__link">
<Link
to={`${PATHS.MANAGE_HOSTS}?operating_system_id=${cellProps.row.original.os_id}`}
className="hosts-link"
>
<span className="link-text">View all hosts</span>
<img
alt="link to hosts filtered by operating system ID"
src={Chevron}
/>
</Link>
</span>
</span>
);
},
},
];
const generateTableHeaders = (): IDataColumn[] => {
return osTableHeaders;
return defaultTableHeaders;
};
export default generateTableHeaders;

View file

@ -2,14 +2,8 @@
margin-top: $pad-large;
position: relative;
.data-table__wrapper {
overflow-x: auto;
}
.component__tabs-wrapper .table-container__header {
display: none;
}
&__empty-os {
margin: $pad-medium auto 0;
margin: 0 auto;
h1 {
font-size: $small;
@ -24,40 +18,58 @@
margin: 0;
}
}
.data-table-container {
.data-table__table {
table-layout: fixed;
thead {
.hosts_count__header {
border-right: 0;
padding-right: 0;
width: 84px;
}
}
.data-table__wrapper {
overflow-x: auto;
}
tbody {
.name__cell,
.version__cell {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.id__cell {
padding: 0;
width: 40px;
}
.vulnerabilities__cell {
img {
transform: scale(0.5);
}
}
}
.hosts-cell__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
a {
display: flex;
align-items: center;
}
img {
height: 16px;
}
}
.hosts-link {
color: $core-vibrant-blue;
visibility: hidden;
font-weight: bold;
text-decoration: none;
vertical-align: middle;
a {
text-decoration: none;
}
img {
height: 16px;
width: 16px;
vertical-align: middle;
}
.link-text {
padding-right: $pad-xxsmall;
}
}
tr:hover {
.hosts-link {
visibility: visible;
}
}
.count-loading {
color: $ui-fleet-black-50;
}
.count-error {
color: $ui-error;
}

View file

@ -27,16 +27,19 @@ const baseClass = "homepage-info-card";
const useInfoCard = ({
title,
description,
description: defaultDescription,
children,
action,
total_host_count,
showTitle,
showTitle = true,
}: IInfoCardProps): JSX.Element => {
const [actionLink, setActionURL] = useState<string | null>(null);
const [titleDetail, setTitleDetail] = useState<JSX.Element | string | null>(
null
);
const [description, setDescription] = useState<JSX.Element | string | null>(
defaultDescription || null
);
const renderAction = () => {
if (action) {
@ -77,6 +80,7 @@ const useInfoCard = ({
if (React.isValidElement(child)) {
child = React.cloneElement(child, {
setTitleDetail,
setTitleDescription: setDescription,
setActionURL,
});
}

View file

@ -51,6 +51,10 @@
&__section-description {
font-size: $x-small;
p {
margin: 8px 0 0 0;
}
a {
font-size: $x-small;
color: $core-vibrant-blue;

View file

@ -19,6 +19,11 @@ import hostsAPI, {
import hostCountAPI, {
IHostCountLoadOptions,
} from "services/entities/host_count";
import {
getOSVersions,
IGetOSVersionsQueryKey,
IOSVersionsResponse,
} from "services/entities/operating_systems";
import PATHS from "router/paths";
import { AppContext } from "context/app";
@ -32,6 +37,7 @@ import {
import { IApiError } from "interfaces/errors";
import { IHost } from "interfaces/host";
import { ILabel, ILabelFormData } from "interfaces/label";
import { IOperatingSystemVersion } from "interfaces/operating_system";
import { IPolicy } from "interfaces/policy";
import { ISoftware } from "interfaces/software";
import { ITeam } from "interfaces/team";
@ -262,6 +268,10 @@ const ManageHostsPage = ({
queryParams?.software_id !== undefined
? parseInt(queryParams?.software_id, 10)
: undefined;
const operatingSystemId =
queryParams?.operating_system_id !== undefined
? parseInt(queryParams?.operating_system_id, 10)
: undefined;
const { active_label: activeLabel, label_id: labelID } = routeParams;
// ===== filter matching
@ -356,6 +366,17 @@ const ManageHostsPage = ({
}
);
const { data: osVersions } = useQuery<
IOSVersionsResponse,
Error,
IOperatingSystemVersion[],
IGetOSVersionsQueryKey[]
>([{ scope: "os_versions" }], () => getOSVersions(), {
enabled: !!queryParams?.operating_system_id,
keepPreviousData: true,
select: (data) => data.os_versions,
});
const toggleDeleteSecretModal = () => {
// open and closes delete modal
setShowDeleteSecretModal(!showDeleteSecretModal);
@ -454,6 +475,10 @@ const ManageHostsPage = ({
options.teamId = queryParams.team_id;
}
if (queryParams.operating_system_id) {
options.operatingSystemId = queryParams.operating_system_id;
}
try {
const { count: returnedHostCount } = await hostCountAPI.load(options);
setFilteredHostCount(returnedHostCount);
@ -509,6 +534,7 @@ const ManageHostsPage = ({
policyId,
policyResponse,
softwareId,
operatingSystemId,
page: tableQueryData ? tableQueryData.pageIndex : 0,
perPage: tableQueryData ? tableQueryData.pageSize : 100,
device_mapping: true,
@ -602,6 +628,17 @@ const ManageHostsPage = ({
);
};
const handleClearOSFilter = () => {
router.replace(
getNextLocationPath({
pathPrefix: PATHS.MANAGE_HOSTS,
routeTemplate,
routeParams,
queryParams: omit(queryParams, ["operating_system_id"]),
})
);
};
const handleClearSoftwareFilter = () => {
router.replace(PATHS.MANAGE_HOSTS);
setCurrentTeam(undefined);
@ -735,7 +772,9 @@ const ManageHostsPage = ({
if (softwareId && !policyId) {
newQueryParams.software_id = softwareId;
}
if (operatingSystemId && !softwareId && !policyId) {
newQueryParams.operating_system_id = operatingSystemId;
}
router.replace(
getNextLocationPath({
pathPrefix: PATHS.MANAGE_HOSTS,
@ -752,6 +791,7 @@ const ManageHostsPage = ({
policyId,
queryParams,
softwareId,
operatingSystemId,
sortBy,
]
);
@ -1063,6 +1103,53 @@ const ManageHostsPage = ({
/>
);
const renderOSFilterBlock = () => {
const os = osVersions?.find((v) => v.os_id === operatingSystemId);
if (!os) {
return <></>;
}
const { name, name_only, version } = os;
const buttonText =
name_only || version
? `${name_only || ""} ${version || ""}`
: `${name || ""}`;
return (
<div className={`${baseClass}__software-filter-block`}>
<div>
<span
data-tip
data-for="software-filter-tooltip"
data-tip-disable={!name_only || !version || !name}
>
<div className={`${baseClass}__software-filter-name-card tooltip`}>
{buttonText}
<Button
className={`${baseClass}__clear-policies-filter`}
onClick={handleClearOSFilter}
variant={"small-text-icon"}
title={buttonText}
>
<img src={CloseIcon} alt="Remove os filter" />
</Button>
</div>
</span>
<ReactTooltip
place="bottom"
effect="solid"
backgroundColor="#3e4771"
id="software-filter-tooltip"
data-html
>
<span className={`tooltip__tooltip-text`}>
{`Hosts with ${name_only || name}`},<br />
{version && `${version} installed`}
</span>
</ReactTooltip>
</div>
</div>
);
};
const renderPoliciesFilterBlock = () => (
<div className={`${baseClass}__policies-filter-block`}>
<PoliciesFilter
@ -1384,7 +1471,7 @@ const ManageHostsPage = ({
selectedLabel &&
selectedLabel.type !== "all" &&
selectedLabel.type !== "status";
if (policyId || softwareId || showSelectedLabel) {
if (policyId || softwareId || showSelectedLabel || operatingSystemId) {
return (
<div className={`${baseClass}__labels-active-filter-wrap`}>
{showSelectedLabel && renderHeaderLabelBlock()}
@ -1396,6 +1483,11 @@ const ManageHostsPage = ({
!policyId &&
!showSelectedLabel &&
renderSoftwareFilterBlock()}
{!!operatingSystemId &&
!policyId &&
!softwareId &&
!showSelectedLabel &&
renderOSFilterBlock()}
</div>
);
}
@ -1495,14 +1587,18 @@ const ManageHostsPage = ({
!isHostsLoading &&
teamSync
) {
const { software_id, policy_id } = queryParams || {};
const includesSoftwareOrPolicyFilter = !!(software_id || policy_id);
const { software_id, policy_id, operating_system_id } = queryParams || {};
const includesSoftwareOrPolicyOrOSFilter = !!(
software_id ||
policy_id ||
operating_system_id
);
return (
<NoHosts
toggleAddHostsModal={toggleAddHostsModal}
canEnrollHosts={canEnrollHosts}
includesSoftwareOrPolicyFilter={includesSoftwareOrPolicyFilter}
includesSoftwareOrPolicyFilter={includesSoftwareOrPolicyOrOSFilter}
/>
);
}

View file

@ -17,6 +17,7 @@ export interface IHostCountLoadOptions {
policyId?: number;
policyResponse?: string;
softwareId?: number;
operatingSystemId?: number;
}
export default {
@ -29,6 +30,7 @@ export default {
const policyResponse = options?.policyResponse || null;
const selectedLabels = options?.selectedLabels || [];
const softwareId = options?.softwareId || null;
const operatingSystemId = options?.operatingSystemId || null;
const labelPrefix = "labels/";
@ -68,6 +70,10 @@ export default {
queryString += `&software_id=${softwareId}`;
}
if (!label && !policyId && !softwareId && operatingSystemId) {
queryString += `&operating_system_id=${operatingSystemId}`;
}
// Append query string to endpoint route after slicing off the leading ampersand
const path = `${HOSTS_COUNT}${queryString && `?${queryString.slice(1)}`}`;

View file

@ -19,6 +19,7 @@ export interface ILoadHostsOptions {
policyId?: number;
policyResponse?: string;
softwareId?: number;
operatingSystemId?: number;
device_mapping?: boolean;
columns?: string;
visibleColumns?: string;
@ -34,6 +35,7 @@ export interface IExportHostsOptions {
policyId?: number;
policyResponse?: string;
softwareId?: number;
operatingSystemId?: number;
device_mapping?: boolean;
columns?: string;
visibleColumns?: string;
@ -104,6 +106,19 @@ const getSoftwareParam = (
return label === undefined && policyId === undefined ? softwareId : undefined;
};
const getOperatingSystemParam = (
label?: string,
policyId?: number,
softwareId?: number,
operatingSystemId?: number
) => {
return label === undefined &&
policyId === undefined &&
softwareId === undefined
? operatingSystemId
: undefined;
};
export default {
destroy: (host: IHost) => {
const { HOSTS } = endpoints;
@ -202,6 +217,7 @@ export default {
policyId,
policyResponse = "passing",
softwareId,
operatingSystemId,
device_mapping,
selectedLabels,
sortBy,
@ -221,6 +237,13 @@ export default {
policy_id: policyParams.policy_id,
policy_response: policyParams.policy_response,
software_id: getSoftwareParam(label, policyId, softwareId),
operating_system_id: getOperatingSystemParam(
label,
policyId,
softwareId,
operatingSystemId
),
status: getStatusParam(selectedLabels),
};

View file

@ -5,30 +5,39 @@ import { IOperatingSystemVersion } from "interfaces/operating_system";
import { IOsqueryPlatform } from "interfaces/platform";
import { buildQueryStringFromParams } from "utilities/url";
export interface IOperatingSystemsResponse {
// TODO: add platforms to this constant as new ones are supported
export const OS_VERSIONS_API_SUPPORTED_PLATFORMS: IOsqueryPlatform[] = [
"darwin",
"windows",
];
export interface IGetOSVersionsRequest {
id?: number;
platform?: IOsqueryPlatform;
teamId?: number;
}
export interface IGetOSVersionsQueryKey extends IGetOSVersionsRequest {
scope: string;
}
export interface IOSVersionsResponse {
counts_updated_at: string;
os_versions: IOperatingSystemVersion[];
}
interface IGetVersionParams {
platform: IOsqueryPlatform;
teamId?: number;
}
export const getOSVersions = async ({
id,
platform,
teamId,
}: IGetOSVersionsRequest = {}): Promise<IOSVersionsResponse> => {
const { OS_VERSIONS } = endpoints;
const queryParams = { id, platform, team_id: teamId };
const queryString = buildQueryStringFromParams(queryParams);
const path = `${OS_VERSIONS}?${queryString}`;
return sendRequest("GET", path);
};
export default {
getVersions: async ({
platform,
teamId,
}: IGetVersionParams): Promise<IOperatingSystemsResponse> => {
const { OS_VERSIONS } = endpoints;
const queryParams = { platform, team_id: teamId };
const queryString = buildQueryStringFromParams(queryParams);
const path = `${OS_VERSIONS}?${queryString}`;
try {
return sendRequest("GET", path);
} catch (error) {
return Promise.reject(error);
}
},
getOSVersions,
};