diff --git a/changes/issue-6475-host-operating-system b/changes/issue-6475-host-operating-system index 874f6787fd..9646239276 100644 --- a/changes/issue-6475-host-operating-system +++ b/changes/issue-6475-host-operating-system @@ -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 diff --git a/cypress/integration/all/app/dashboard.spec.ts b/cypress/integration/all/app/dashboard.spec.ts index 8262c6c441..60a2d3bfd5 100644 --- a/cypress/integration/all/app/dashboard.spec.ts +++ b/cypress/integration/all/app/dashboard.spec.ts @@ -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"); + }); }); }); diff --git a/frontend/interfaces/operating_system.ts b/frontend/interfaces/operating_system.ts index 35358e14cc..635859c6aa 100644 --- a/frontend/interfaces/operating_system.ts +++ b/frontend/interfaces/operating_system.ts @@ -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 = { + darwin: "Apple", + windows: "Microsoft", +} as const; + +export const OS_END_OF_LIFE_LINK_BY_PLATFORM: Record = { + darwin: "https://endoflife.date/macos", + windows: "https://endoflife.date/windows", +} as const; diff --git a/frontend/pages/Homepage/Homepage.tsx b/frontend/pages/Homepage/Homepage.tsx index 966f2ad62d..67e4f5af0c 100644 --- a/frontend/pages/Homepage/Homepage.tsx +++ b/frontend/pages/Homepage/Homepage.tsx @@ -304,8 +304,8 @@ const Homepage = (): JSX.Element => { ), }); @@ -336,7 +336,9 @@ const Homepage = (): JSX.Element => { ); - const windowsLayout = () => null; + const windowsLayout = () => ( +
{OperatingSystemsCard}
+ ); const linuxLayout = () => null; const renderCards = () => { diff --git a/frontend/pages/Homepage/cards/OperatingSystems/OperatingSystems.tsx b/frontend/pages/Homepage/cards/OperatingSystems/OperatingSystems.tsx index 82dd7ec39c..bead88a997 100644 --- a/frontend/pages/Homepage/cards/OperatingSystems/OperatingSystems.tsx +++ b/frontend/pages/Homepage/cards/OperatingSystems/OperatingSystems.tsx @@ -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 => (
-

{`No ${ - PLATFORM_DISPLAY_NAMES[platform] || "supported" - } operating systems detected`}

+

{`No${ + ` ${PLATFORM_DISPLAY_NAMES[platform]}` || "" + } operating systems detected.`}

{`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( - - ); - }, - onError: () => { - setShowOperatingSystemsUI(true); - }, } ); + const description = + OS_VENDOR_BY_PLATFORM[selectedPlatform] && + OS_END_OF_LIFE_LINK_BY_PLATFORM[selectedPlatform] ? ( +

+ {OS_VENDOR_BY_PLATFORM[selectedPlatform]} releases updates and fixes for + supported operating systems.{" "} + + See supported operating systems + +

+ ) : null; + + const titleDetail = osInfo?.counts_updated_at ? ( + + ) : 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 (
- {!showOperatingSystemsUI && !errorOS && ( + {isFetching && (
)}
- {errorOS ? ( + {error ? ( ) : ( )}
diff --git a/frontend/pages/Homepage/cards/OperatingSystems/OperatingSystemsTableConfig.tsx b/frontend/pages/Homepage/cards/OperatingSystems/OperatingSystemsTableConfig.tsx index 5a1c0ff0cd..3e367110d7 100644 --- a/frontend/pages/Homepage/cards/OperatingSystems/OperatingSystemsTableConfig.tsx +++ b/frontend/pages/Homepage/cards/OperatingSystems/OperatingSystemsTableConfig.tsx @@ -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) => , + }, + { + title: "Version", + Header: "Version", + disableSortBy: true, + accessor: "version", Cell: (cellProps: ICellProps) => , }, { @@ -50,12 +59,32 @@ const osTableHeaders = [ ), disableSortBy: false, accessor: "hosts_count", - Cell: (cellProps: ICellProps) => , + Cell: (cellProps: ICellProps): JSX.Element => { + return ( + + + + + + + View all hosts + link to hosts filtered by operating system ID + + + + ); + }, }, ]; const generateTableHeaders = (): IDataColumn[] => { - return osTableHeaders; + return defaultTableHeaders; }; export default generateTableHeaders; diff --git a/frontend/pages/Homepage/cards/OperatingSystems/_styles.scss b/frontend/pages/Homepage/cards/OperatingSystems/_styles.scss index ea2e732565..05d96d1b14 100644 --- a/frontend/pages/Homepage/cards/OperatingSystems/_styles.scss +++ b/frontend/pages/Homepage/cards/OperatingSystems/_styles.scss @@ -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; } diff --git a/frontend/pages/Homepage/components/InfoCard/InfoCard.tsx b/frontend/pages/Homepage/components/InfoCard/InfoCard.tsx index 4085ee95af..80906506f1 100644 --- a/frontend/pages/Homepage/components/InfoCard/InfoCard.tsx +++ b/frontend/pages/Homepage/components/InfoCard/InfoCard.tsx @@ -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(null); const [titleDetail, setTitleDetail] = useState( null ); + const [description, setDescription] = useState( + defaultDescription || null + ); const renderAction = () => { if (action) { @@ -77,6 +80,7 @@ const useInfoCard = ({ if (React.isValidElement(child)) { child = React.cloneElement(child, { setTitleDetail, + setTitleDescription: setDescription, setActionURL, }); } diff --git a/frontend/pages/Homepage/components/InfoCard/_styles.scss b/frontend/pages/Homepage/components/InfoCard/_styles.scss index 6113a1f314..93c7bd5a10 100644 --- a/frontend/pages/Homepage/components/InfoCard/_styles.scss +++ b/frontend/pages/Homepage/components/InfoCard/_styles.scss @@ -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; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 029789ff33..cfa648ee15 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -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 ( +
+
+ +
+ {buttonText} + +
+
+ + + {`Hosts with ${name_only || name}`},
+ {version && `${version} installed`} +
+
+
+
+ ); + }; + const renderPoliciesFilterBlock = () => (
{showSelectedLabel && renderHeaderLabelBlock()} @@ -1396,6 +1483,11 @@ const ManageHostsPage = ({ !policyId && !showSelectedLabel && renderSoftwareFilterBlock()} + {!!operatingSystemId && + !policyId && + !softwareId && + !showSelectedLabel && + renderOSFilterBlock()}
); } @@ -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 ( ); } diff --git a/frontend/services/entities/host_count.ts b/frontend/services/entities/host_count.ts index c806f4d3c2..41f0f63662 100644 --- a/frontend/services/entities/host_count.ts +++ b/frontend/services/entities/host_count.ts @@ -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)}`}`; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index a44ba15945..8defc94323 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -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), }; diff --git a/frontend/services/entities/operating_systems.ts b/frontend/services/entities/operating_systems.ts index fd3aba1579..2a1c8d055e 100644 --- a/frontend/services/entities/operating_systems.ts +++ b/frontend/services/entities/operating_systems.ts @@ -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 => { + 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 => { - 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, };