From 6bfbb55af9da664353bf78613390fafb24a8bffa Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:33:36 -0400 Subject: [PATCH] Fleet UI: Add minimum version requirement information to Host Details page (#20505) --- changes/20100-os-version-compliance | 1 + frontend/__mocks__/configMock.ts | 8 +++ frontend/interfaces/activity.ts | 2 + frontend/interfaces/config.ts | 12 ++-- frontend/interfaces/host.ts | 5 +- frontend/interfaces/platform.ts | 6 ++ frontend/interfaces/team.ts | 13 +++-- .../ActivityItem/ActivityItem.tsx | 13 ++++- .../HostDetailsPage/HostDetailsPage.tsx | 20 +++++++ .../details/cards/HostSummary/HostSummary.tsx | 55 ++++++++++++++++++- frontend/services/entities/teams.ts | 8 +++ frontend/utilities/helpers.tests.tsx | 49 +++++++++++++++++ frontend/utilities/helpers.tsx | 33 +++++++++++ 13 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 changes/20100-os-version-compliance create mode 100644 frontend/utilities/helpers.tests.tsx diff --git a/changes/20100-os-version-compliance b/changes/20100-os-version-compliance new file mode 100644 index 0000000000..f14334f97f --- /dev/null +++ b/changes/20100-os-version-compliance @@ -0,0 +1 @@ +- Fleet UI: Show OS version compliance on Host Details page diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 28519b9613..bcaaffb9ee 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -11,6 +11,14 @@ const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = { minimum_version: "", deadline: "", }, + ios_updates: { + minimum_version: "", + deadline: "", + }, + ipados_updates: { + minimum_version: "", + deadline: "", + }, macos_settings: { custom_settings: null, enable_disk_encryption: false, diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 127945310b..ae1d2a6e58 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -35,6 +35,8 @@ export enum ActivityType { MdmEnrolled = "mdm_enrolled", MdmUnenrolled = "mdm_unenrolled", EditedMacosMinVersion = "edited_macos_min_version", + EditedIosMinVersion = "edited_ios_min_version", + EditedIpadosMinVersion = "edited_ipados_min_version", ReadHostDiskEncryptionKey = "read_host_disk_encryption_key", /** Note: BE not renamed (yet) from macOS even though activity is also used for iOS and iPadOS */ CreatedAppleOSProfile = "created_macos_profile", diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index e4118b83d1..8ba36f0fa0 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -35,6 +35,11 @@ interface ICustomSetting { labels_exclude_any?: string[]; } +export interface IAppleDeviceUpdates { + minimum_version: string; + deadline: string; +} + export interface IMdmConfig { enable_disk_encryption: boolean; /** `enabled_and_configured` only tells us if Apples MDM has been enabled and @@ -46,10 +51,9 @@ export interface IMdmConfig { apple_bm_enabled_and_configured: boolean; windows_enabled_and_configured: boolean; end_user_authentication: IEndUserAuthentication; - macos_updates: { - minimum_version: string | null; - deadline: string | null; - }; + macos_updates: IAppleDeviceUpdates; + ios_updates: IAppleDeviceUpdates; + ipados_updates: IAppleDeviceUpdates; macos_settings: { custom_settings: null | ICustomSetting[]; enable_disk_encryption: boolean; diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 604b0c761b..dcb48c8bd1 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -13,6 +13,7 @@ import { BootstrapPackageStatus, DiskEncryptionStatus, } from "./mdm"; +import { HostPlatform } from "./platform"; export default PropTypes.shape({ created_at: PropTypes.string, @@ -243,7 +244,7 @@ export interface IDeviceUserResponse { org_logo_url: string; org_contact_url: string; disk_encryption_enabled?: boolean; - platform?: string; + platform?: HostPlatform; global_config: IDeviceGlobalConfig; self_service: boolean; } @@ -277,7 +278,7 @@ export interface IHost { refetch_critical_queries_until: string | null; hostname: string; uuid: string; - platform: string; + platform: HostPlatform; osquery_version: string; orbit_version: string | null; fleet_desktop_version: string | null; diff --git a/frontend/interfaces/platform.ts b/frontend/interfaces/platform.ts index 5c9a9042cd..e26a653339 100644 --- a/frontend/interfaces/platform.ts +++ b/frontend/interfaces/platform.ts @@ -82,6 +82,12 @@ export const HOST_LINUX_PLATFORMS = [ export const HOST_APPLE_PLATFORMS = ["darwin", "ios", "ipados"] as const; +export type HostPlatform = + | typeof HOST_LINUX_PLATFORMS[number] + | typeof HOST_APPLE_PLATFORMS[number] + | "windows" + | "chrome"; + /** * Checks if the provided platform is a Linux-like OS. We can recieve many * different types of host platforms so we need a check that will cover all diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 1939b8d93b..b0bf6ca6e5 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -1,5 +1,9 @@ import PropTypes from "prop-types"; -import { IConfigFeatures, IWebhookSettings } from "./config"; +import { + IAppleDeviceUpdates, + IConfigFeatures, + IWebhookSettings, +} from "./config"; import enrollSecretInterface, { IEnrollSecret } from "./enroll_secret"; import { ITeamIntegrations } from "./integration"; import { UserRole } from "./user"; @@ -45,10 +49,9 @@ export interface ITeam extends ITeamSummary { role?: UserRole; // role value is included when the team is in the context of a user mdm?: { enable_disk_encryption: boolean; - macos_updates: { - minimum_version: string | null; - deadline: string | null; - }; + macos_updates: IAppleDeviceUpdates; + ios_updates: IAppleDeviceUpdates; + ipados_updates: IAppleDeviceUpdates; macos_settings: { custom_settings: null; // TODO: types? enable_disk_encryption: boolean; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 18530b33f5..74e6fbff1c 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -304,7 +304,10 @@ const TAGGED_TEMPLATES = { ); }, - editedMacosMinVersion: (activity: IActivity) => { + editedAppleosMinVersion: ( + activity: IActivity, + osType: "darwin" | "ios" | "ipados" + ) => { const editedActivity = activity.details?.minimum_version === "" ? "removed" : "updated"; @@ -974,7 +977,13 @@ const getDetail = ( return TAGGED_TEMPLATES.mdmUnenrolled(activity); } case ActivityType.EditedMacosMinVersion: { - return TAGGED_TEMPLATES.editedMacosMinVersion(activity); + return TAGGED_TEMPLATES.editedAppleosMinVersion(activity, "darwin"); + } + case ActivityType.EditedIosMinVersion: { + return TAGGED_TEMPLATES.editedAppleosMinVersion(activity, "ios"); + } + case ActivityType.EditedIpadosMinVersion: { + return TAGGED_TEMPLATES.editedAppleosMinVersion(activity, "ipados"); } case ActivityType.ReadHostDiskEncryptionKey: { diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index f1b2589b11..71cf363319 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -450,6 +450,23 @@ const HostDetailsPage = ({ ? teams?.find((t) => t.id === host.team_id)?.features : config?.features; + const getOSVersionRequirementFromMDMConfig = (hostPlatform: string) => { + const mdmConfig = host?.team_id + ? teams?.find((t) => t.id === host.team_id)?.mdm + : config?.mdm; + + switch (hostPlatform) { + case "darwin": + return mdmConfig?.macos_updates; + case "ipados": + return mdmConfig?.ipados_updates; + case "ios": + return mdmConfig?.ios_updates; + default: + null; + } + }; + useEffect(() => { setUsersState(() => { return ( @@ -810,6 +827,9 @@ const HostDetailsPage = ({ onRefetchHost={onRefetchHost} renderActionDropdown={renderActionDropdown} osSettings={host?.mdm.os_settings} + osVersionRequirement={getOSVersionRequirementFromMDMConfig( + host.platform + )} hostMdmDeviceStatus={hostMdmDeviceStatus} /> diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index c47599e9b4..e8bd2cac58 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -8,6 +8,7 @@ import { isWindowsDiskEncryptionStatus, } from "interfaces/mdm"; import { IOSSettings, IHostMaintenanceWindow } from "interfaces/host"; +import { IAppleDeviceUpdates } from "interfaces/config"; import getHostStatusTooltipText from "pages/hosts/helpers"; import TooltipWrapper from "components/TooltipWrapper"; @@ -19,7 +20,12 @@ import StatusIndicator from "components/StatusIndicator"; import IssuesIndicator from "pages/hosts/components/IssuesIndicator"; import DiskSpaceIndicator from "pages/hosts/components/DiskSpaceIndicator"; import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip"; -import { humanHostMemory, wrapFleetHelper } from "utilities/helpers"; +import { + humanHostMemory, + wrapFleetHelper, + removeOSPrefix, + compareVersions, +} from "utilities/helpers"; import { DATE_FNS_FORMAT_STRINGS, DEFAULT_EMPTY_CELL_VALUE, @@ -120,6 +126,7 @@ interface IHostSummaryProps { ) => void; renderActionDropdown: () => JSX.Element | null; deviceUser?: boolean; + osVersionRequirement?: IAppleDeviceUpdates; osSettings?: IOSSettings; hostMdmDeviceStatus?: HostMdmDeviceStatusUIState; } @@ -166,6 +173,26 @@ const getHostDiskEncryptionTooltipMessage = ( ]; }; +const getOSVersionRequirementTooltipMessage = ( + osVersion: string, + osVersionRequirement: IAppleDeviceUpdates +) => { + const requirementMetTooltip = "Meets minimum version requirement."; + const requirementNotMetTooltip = ( + <> + Does not meet minimum version requirement. +
+ Deadline to update: {osVersionRequirement.deadline} + + ); + + const result = compareVersions( + removeOSPrefix(osVersion), + osVersionRequirement.minimum_version + ); + return result < 0 ? requirementNotMetTooltip : requirementMetTooltip; +}; + const HostSummary = ({ summaryData, bootstrapPackageData, @@ -178,6 +205,7 @@ const HostSummary = ({ onRefetchHost, renderActionDropdown, deviceUser, + osVersionRequirement, osSettings, hostMdmDeviceStatus, }: IHostSummaryProps): JSX.Element => { @@ -311,6 +339,29 @@ const HostSummary = ({ ); }; + const renderOperatingSystemSummary = () => { + // No tooltip if minimum version is not set, including all Windows, Linux, ChromeOS operating systems + return ( + + {summaryData.os_version} + + ) : ( + summaryData.os_version + ) + } + /> + ); + }; + const renderAgentSummary = () => { if (isChromeHost) { return ; @@ -472,7 +523,7 @@ const HostSummary = ({ {!isIosOrIpadosHost && ( )} - + {renderOperatingSystemSummary()} {!isIosOrIpadosHost && renderAgentSummary()} {isPremiumTier && // TODO - refactor normalizeEmptyValues pattern diff --git a/frontend/services/entities/teams.ts b/frontend/services/entities/teams.ts index 8bea47a64d..6a78909a82 100644 --- a/frontend/services/entities/teams.ts +++ b/frontend/services/entities/teams.ts @@ -45,6 +45,14 @@ export interface IUpdateTeamFormData { minimum_version: string; deadline: string; }; + ios_updates?: { + minimum_version: string; + deadline: string; + }; + ipados_updates?: { + minimum_version: string; + deadline: string; + }; windows_updates?: { deadline_days: number; grace_period_days: number; diff --git a/frontend/utilities/helpers.tests.tsx b/frontend/utilities/helpers.tests.tsx new file mode 100644 index 0000000000..3efbeb3a3b --- /dev/null +++ b/frontend/utilities/helpers.tests.tsx @@ -0,0 +1,49 @@ +import { removeOSPrefix, compareVersions } from "./helpers"; + +describe("helpers utilities", () => { + describe("removeOSPrefix function", () => { + it("properly removes Apple prefix from a host's operating system version", () => { + expect(removeOSPrefix("macOS 14.1.2")).toEqual("14.1.2"); + expect(removeOSPrefix("iOS 18.0")).toEqual("18.0"); + expect(removeOSPrefix("iPadOS 17.5.1")).toEqual("17.5.1"); + }); + }); + + describe("compareVersions function", () => { + it("properly checks if a version is older than another", () => { + expect(compareVersions("14.4.1", "14.4.2")).toEqual(-1); + expect(compareVersions("14.4.1", "14.5")).toEqual(-1); + expect(compareVersions("14.4.1", "15")).toEqual(-1); + + expect(compareVersions("14.4", "14.4.2")).toEqual(-1); + expect(compareVersions("14.4", "14.5")).toEqual(-1); + expect(compareVersions("14.4", "15")).toEqual(-1); + + expect(compareVersions("14", "14.4.2")).toEqual(-1); + expect(compareVersions("14", "14.0.5")).toEqual(-1); + expect(compareVersions("14", "15")).toEqual(-1); + }); + + it("properly checks if a version is newer than another", () => { + expect(compareVersions("14.4.4", "14.4.3")).toEqual(1); + expect(compareVersions("14.3.4", "14.3")).toEqual(1); + expect(compareVersions("14.0.4", "14")).toEqual(1); + + expect(compareVersions("14.5", "14.4.3")).toEqual(1); + expect(compareVersions("14.5", "14.3")).toEqual(1); + expect(compareVersions("14.5", "14")).toEqual(1); + + expect(compareVersions("14", "13.9.21")).toEqual(1); + expect(compareVersions("14", "13.9")).toEqual(1); + expect(compareVersions("14", "13")).toEqual(1); + }); + + it("properly checks if a version is equal to another", () => { + expect(compareVersions("14.0.4", "14.0.4")).toEqual(0); + expect(compareVersions("14.3", "14.3")).toEqual(0); + expect(compareVersions("14", "14")).toEqual(0); + expect(compareVersions("14.3", "14.3.0")).toEqual(0); + expect(compareVersions("14", "14.0.0")).toEqual(0); + }); + }); +}); diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index f050ef02c4..efd67bb371 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -104,6 +104,37 @@ export const createHostsByPolicyPath = ( })}`; }; +/** Removes Apple OS Prefix from host.os_version. */ +export const removeOSPrefix = (version: string): string => { + return version.replace(/^(macOS |iOS |iPadOS )/i, ""); +}; + +/** Returns 1 if first version is newer, -1 if first version is older, and 0 if equal */ +export const compareVersions = (version1: string, version2: string) => { + const v1Parts = version1.split(".").map(Number); + const v2Parts = version2.split(".").map(Number); + + const maxLength = Math.max(v1Parts.length, v2Parts.length); + + // Create a new array with a length of maxLength, mapping each index to a comparison result + return ( + Array.from({ length: maxLength }, (_, index) => { + // Retrieve the corresponding parts from v1Parts and v2Parts, defaulting to 0 + const v1Part = v1Parts[index] || 0; + const v2Part = v2Parts[index] || 0; + + // Compare the current parts and return -1, 1, or 0 based on the result + if (v1Part < v2Part) return -1; + if (v1Part > v2Part) return 1; + return 0; + }) + // Use Array.find to return the first non-equal version number in the comparison array + .find((result) => result !== 0) || + // If no difference is found, return 0 to indicate equal versions + 0 + ); +}; + const labelSlug = (label: ILabel): string => { const { id, name } = label; @@ -978,6 +1009,8 @@ export function getCustomDropdownOptions( export default { addGravatarUrlToResource, + removeOSPrefix, + compareVersions, createHostsByPolicyPath, formatConfigDataForServer, formatLabelResponse,