47717 auld UI latest os version (#50571)

**Related issue:** Resolves #47717

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters. _Front end only_

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually






<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Apple OS update targeting options for no enforcement, a custom
minimum version, or the latest available version.
* Added configurable whole-day deadlines for macOS, iOS, and iPadOS
updates.
* Added platform-specific target controls, validation, and automatic
new-host updates for latest-version targeting.
* Displayed minimum versions, pending status, and update deadlines in
host details and activity feeds.
  * Clarified Windows deadlines as days after release.

* **Style**
  * Improved layout and spacing for Apple and Windows update forms.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Jordan Montgomery <elijah.jordan.montgomery@gmail.com>
This commit is contained in:
Andrew Mellor
2026-08-07 18:25:37 +01:00
committed by GitHub
co-authored by Jordan Montgomery
parent 4d55e96f9f
commit eb4acf4d1e
18 changed files with 1153 additions and 113 deletions
+3
View File
@@ -18,14 +18,17 @@ const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = {
macos_updates: {
minimum_version: "",
deadline: "",
deadline_days: null,
},
ios_updates: {
minimum_version: "",
deadline: "",
deadline_days: null,
},
ipados_updates: {
minimum_version: "",
deadline: "",
deadline_days: null,
},
apple_settings: {
configuration_profiles: null,
+4
View File
@@ -40,8 +40,12 @@ interface ICustomSetting {
}
export interface IAppleDeviceUpdates {
/** The sentinel `"latest"` enforces the newest version available, with the
* deadline derived from `deadline_days` instead of a fixed date. */
minimum_version: string;
deadline: string;
/** Only set when `minimum_version` is `"latest"`; null otherwise. */
deadline_days: number | null;
update_new_hosts?: boolean;
}
+7
View File
@@ -458,6 +458,13 @@ export interface IHost {
conditional_access_bypassed: boolean;
mdm_enrollment_hardware_attested?: boolean;
dep_assigned_to_fleet: boolean;
/** The OS version this host is required to reach. Null when OS updates
* aren't configured for the host's fleet, and "Pending" while Fleet is still
* resolving the target for a "latest" requirement. */
os_update_minimum_version?: string | null;
/** The date by which os_update_minimum_version must be installed, in
* YYYY-MM-DD. Null and "Pending" follow os_update_minimum_version. */
os_update_deadline?: string | null;
// iOS/iPadOS-only vitals collected via the DeviceInformation MDM command.
// Omitted entirely (not just null) for every other platform.
udid?: string;
@@ -219,6 +219,46 @@ describe("Activity Feed", () => {
expect(screen.getByText("was added to Fleet by SSO.")).toBeInTheDocument();
});
it("renders an edited_macos_min_version activity for a specific version", () => {
const activity = createMockActivity({
type: ActivityType.EditedMacosMinVersion,
details: {
team_id: 1,
team_name: "Workstations",
minimum_version: "14.6.1",
deadline: "2026-09-01",
},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText(/updated the minimum macOS version/)
).toBeInTheDocument();
expect(screen.getByText("14.6.1")).toBeInTheDocument();
expect(screen.getByText(/deadline: 2026-09-01/)).toBeInTheDocument();
});
it("renders an edited_macos_min_version activity for the latest target", () => {
// "latest" is a mode rather than a version, so the sentence must not call it
// a minimum, and there's no deadline to report.
const activity = createMockActivity({
type: ActivityType.EditedMacosMinVersion,
details: {
team_id: 1,
team_name: "Workstations",
minimum_version: "latest",
deadline: "",
},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/updated macOS version to/)).toBeInTheDocument();
expect(screen.getByText("latest")).toBeInTheDocument();
expect(screen.getByText("Workstations")).toBeInTheDocument();
expect(screen.queryByText(/minimum/)).not.toBeInTheDocument();
expect(screen.queryByText(/deadline/)).not.toBeInTheDocument();
});
it("renders an edited_agent_options type activity for a team", () => {
const activity = createMockActivity({
type: ActivityType.EditedAgentOptions,
@@ -521,6 +521,19 @@ const TAGGED_TEMPLATES = {
<>unassigned</>
);
// "latest" isn't a minimum -- the target floats with what Apple publishes --
// so the sentence drops "the minimum" rather than contradicting itself.
// There's no deadline to report either: it's derived from deadline_days,
// which the activity doesn't record.
if (activity.details?.minimum_version === "latest") {
return (
<>
{editedActivity} {applePlatform} version to <b>latest</b> on hosts
assigned to {teamSection}.
</>
);
}
return (
<>
{editedActivity} the minimum {applePlatform} version {versionSection}{" "}
@@ -41,6 +41,7 @@ describe("AppleOSTargetForm", () => {
applePlatform="darwin"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
@@ -69,6 +70,7 @@ describe("AppleOSTargetForm", () => {
applePlatform="darwin"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
@@ -100,6 +102,566 @@ describe("AppleOSTargetForm", () => {
});
});
// Every field is sent for every target: the config PATCH merges key by key,
// so an omitted one would leave the stored value behind.
it("sends the sentinel, no deadline and the days for 'Latest version'", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays="7"
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("latest");
expect(requestBody?.mdm?.macos_updates?.deadline).toBe("");
expect(requestBody?.mdm?.macos_updates?.deadline_days).toBe(7);
});
});
it("clears every field for 'No updates enforced'", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays="7"
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("No updates enforced"));
await user.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("");
expect(requestBody?.mdm?.macos_updates?.deadline).toBe("");
expect(requestBody?.mdm?.macos_updates?.deadline_days).toBeNull();
});
});
it("nulls deadline_days when moving from 'Latest version' to 'Custom version'", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays="7"
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Custom version"));
await user.type(screen.getByLabelText(/Minimum version/i), "15.7.8");
await user.type(screen.getByLabelText(/^Deadline$/i), "2026-09-01");
await user.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("15.7.8");
expect(requestBody?.mdm?.macos_updates?.deadline).toBe("2026-09-01");
// The stored 7 must not survive the switch out of latest mode.
expect(requestBody?.mdm?.macos_updates?.deadline_days).toBeNull();
});
});
it("sends update_new_hosts as true for 'Latest version'", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays="7"
// Stored as false, so a true in the request can only come from the
// target rather than from the prop.
defaultUpdateNewHosts={false}
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(requestBody?.mdm?.macos_updates?.update_new_hosts).toBe(true);
});
});
it("returns the checkbox to the persisted value when the target changes", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts={false}
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
const checkbox = screen.getByRole("checkbox", {
name: /update_new_hosts/i,
});
// Tick it without saving, so the on-screen value differs from what's stored.
await user.click(checkbox);
await waitFor(() => expect(checkbox).toBeChecked());
// Changing the target dismisses that unsaved input.
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("No updates enforced"));
expect(
screen.getByRole("checkbox", { name: /update_new_hosts/i })
).not.toBeChecked();
});
it("seeds the days field from the stored deadline_days", () => {
render(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays="14"
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
const daysInput = screen.getByLabelText(/Days after release/i);
expect((daysInput as HTMLInputElement).value).toBe("14");
});
it("keeps the 'latest' sentinel out of the minimum version input", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays="7"
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
// "latest" is the mode, not a version the user typed, so switching to a
// custom version must start from an empty field rather than the sentinel.
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Custom version"));
const minVersionInput = screen.getByLabelText(/Minimum version/i);
expect((minVersionInput as HTMLInputElement).value).toBe("");
});
it("renders the hardware help text when the stored version is 'latest'", () => {
render(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
expect(screen.getByText(/Based on host hardware\./i)).toBeVisible();
expect(screen.getByRole("link", { name: /Learn more/i })).toHaveAttribute(
"href",
"https://fleetdm.com/learn-more-about/apple-available-os-updates"
);
});
it("shows the hardware help text only once 'Latest version' is selected", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
expect(screen.queryByText(/Based on host hardware\./i)).toBeNull();
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Latest version"));
expect(screen.getByText(/Based on host hardware\./i)).toBeVisible();
});
it("does not render the hardware help text when no updates are enforced", () => {
render(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion=""
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
expect(screen.queryByText(/Based on host hardware\./i)).toBeNull();
});
it("hides the hardware help text when switching away from 'Latest version'", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
expect(screen.getByText(/Based on host hardware\./i)).toBeVisible();
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Custom version"));
expect(screen.queryByText(/Based on host hardware\./i)).toBeNull();
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("No updates enforced"));
expect(screen.queryByText(/Based on host hardware\./i)).toBeNull();
});
it("shows only the days field when 'Latest version' is selected", () => {
render(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/Minimum version/i)).toBeNull();
expect(screen.queryByLabelText(/^Deadline$/i)).toBeNull();
});
it("shows no version fields when no updates are enforced", () => {
render(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion=""
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
expect(screen.queryByLabelText(/Minimum version/i)).toBeNull();
expect(screen.queryByLabelText(/^Deadline$/i)).toBeNull();
expect(screen.queryByLabelText(/Days after release/i)).toBeNull();
});
it("swaps the fields when the target changes", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
expect(screen.getByLabelText(/Minimum version/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/Days after release/i)).toBeNull();
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Latest version"));
expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/Minimum version/i)).toBeNull();
});
it("checks and disables 'update new hosts' for 'Latest version', leaving it visible", () => {
render(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
defaultUpdateNewHosts={false}
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
// The native input is hidden by the Checkbox component's styling, so the
// label is what proves the control is still on screen.
expect(screen.getByText(/Update new hosts to latest/i)).toBeVisible();
const checkbox = screen.getByLabelText(/Update new hosts to latest/i);
expect(checkbox).toBeChecked();
expect(checkbox).toBeDisabled();
});
it("leaves 'update new hosts' editable for 'Custom version'", () => {
render(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts={false}
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
const checkbox = screen.getByLabelText(/Update new hosts to latest/i);
expect(checkbox).not.toBeChecked();
expect(checkbox).toBeEnabled();
});
it("shows the ADE tooltip for 'Latest version' on the checkbox", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.hover(screen.getByText(/Update new hosts to latest/i));
await waitFor(() => {
expect(
screen.getByText(/all hosts will be updated to latest macOS version\./i)
).toBeInTheDocument();
});
});
it("shows the minimum version tooltip on the checkbox for 'Custom version'", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.hover(screen.getByText(/Update new hosts to latest/i));
await waitFor(() => {
expect(
screen.getByText(/hosts below the minimum version are updated/i)
).toBeInTheDocument();
});
});
it("shows the days after release tooltip", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.hover(screen.getByText(/Days after release/i));
await waitFor(() => {
expect(
screen.getByText(
/number of days after Apple releases an update before hosts are required to install it\./i
)
).toBeInTheDocument();
});
});
it("requires a value in days after release before saving", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.click(screen.getByRole("button", { name: /Save/i }));
expect(
await screen.findByText(/The days after release is required\./i)
).toBeInTheDocument();
expect(requestBody).toBeUndefined();
});
it("rejects a days after release value below 1", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.type(screen.getByLabelText(/Days after release/i), "0");
await user.click(screen.getByRole("button", { name: /Save/i }));
expect(
await screen.findByText(/must be a whole number of 1 or more\./i)
).toBeInTheDocument();
expect(requestBody).toBeUndefined();
});
it("rejects a fractional days after release value", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.type(screen.getByLabelText(/Days after release/i), "1.5");
await user.click(screen.getByRole("button", { name: /Save/i }));
expect(
await screen.findByText(/must be a whole number of 1 or more\./i)
).toBeInTheDocument();
expect(requestBody).toBeUndefined();
});
it("does not validate the hidden version fields in 'Latest version'", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.type(screen.getByLabelText(/Days after release/i), "7");
await user.click(screen.getByRole("button", { name: /Save/i }));
// The minimum version and deadline are empty but not on screen, so they
// must not block the save.
expect(screen.queryByText(/The minimum version is required\./i)).toBeNull();
expect(screen.queryByText(/The deadline is required\./i)).toBeNull();
await waitFor(() => expect(requestBody).toBeDefined());
});
it("clears a validation error when the target changes", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="latest"
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.click(screen.getByRole("button", { name: /Save/i }));
expect(
await screen.findByText(/The days after release is required\./i)
).toBeInTheDocument();
// Away and back: the field unmounts either way, so only returning to it
// proves the error state was cleared rather than merely hidden.
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Custom version"));
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Latest version"));
expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument();
expect(
screen.queryByText(/The days after release is required\./i)
).toBeNull();
});
it("requires both fields for 'Custom version' rather than clearing", async () => {
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion=""
defaultDeadline=""
defaultDeadlineDays=""
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
/>
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Custom version"));
await user.click(screen.getByRole("button", { name: /Save/i }));
// Saving an empty custom form used to clear the settings, which is now what
// "No updates enforced" is for.
expect(
await screen.findByText(/The minimum version is required\./i)
).toBeInTheDocument();
expect(screen.getByText(/The deadline is required\./i)).toBeInTheDocument();
expect(requestBody).toBeUndefined();
});
it("renders the correct form for iOS", () => {
render(
<AppleOSTargetForm
@@ -107,6 +669,7 @@ describe("AppleOSTargetForm", () => {
applePlatform="ios"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
@@ -134,6 +697,7 @@ describe("AppleOSTargetForm", () => {
applePlatform="ios"
defaultMinOsVersion="12.0"
defaultDeadline="2025-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
@@ -157,6 +721,7 @@ describe("AppleOSTargetForm", () => {
applePlatform="ipados"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
@@ -184,6 +749,7 @@ describe("AppleOSTargetForm", () => {
applePlatform="ipados"
defaultMinOsVersion="13.0"
defaultDeadline="2026-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts
refetchAppConfig={jest.fn()}
refetchTeamConfig={jest.fn()}
@@ -201,4 +767,54 @@ describe("AppleOSTargetForm", () => {
expect(requestBody?.mdm?.ipados_updates?.deadline).toBe("2026-12-31");
});
});
// A rejected save must not refetch. The refetch feeds the stored config back
// in as props and resets the form, which would discard the very input the user
// needs to correct — e.g. a version Apple doesn't support.
it("keeps the entered version when the server rejects the save", async () => {
let rejected = false;
mockServer.use(
http.patch(baseUrl("/fleets/1"), () => {
rejected = true;
return HttpResponse.json(
{
message: "Validation Failed",
errors: [
{
name: "macos_updates",
reason: "The minimum version isn't supported by Apple.",
},
],
},
{ status: 422 }
);
})
);
const refetchTeamConfig = jest.fn();
const { user } = renderWithBackend(
<AppleOSTargetForm
currentTeamId={1}
applePlatform="darwin"
defaultMinOsVersion="11.0"
defaultDeadline="2024-12-31"
defaultDeadlineDays=""
defaultUpdateNewHosts
refetchAppConfig={jest.fn()}
refetchTeamConfig={refetchTeamConfig}
/>
);
const minVersionInput = screen.getByLabelText(/Minimum version/i);
await user.clear(minVersionInput);
await user.type(minVersionInput, "15.1");
await user.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(rejected).toBe(true);
});
expect((minVersionInput as HTMLInputElement).value).toBe("15.1");
expect(refetchTeamConfig).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,8 @@ import teamsAPI from "services/entities/teams";
import { ApplePlatform } from "interfaces/platform";
import InputField from "components/forms/fields/InputField";
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
import validatePresence from "components/forms/validators/validate_presence";
@@ -20,14 +22,38 @@ import { getErrorMessage } from "./helpers";
const baseClass = "apple-os-target-form";
/** The sentinel stored in minimum_version meaning "enforce the newest version
* available", with the deadline derived from deadline_days rather than a date. */
export const LATEST_VERSION = "latest";
/** Which set of fields the form collects. Not stored separately: minimum_version
* carries the mode, so the target is derived from it. */
export type AppleOSTarget = "none" | "custom" | "latest";
const TARGET_OPTIONS: CustomOptionType[] = [
{ label: "No updates enforced", value: "none" },
{ label: "Custom version", value: "custom" },
{ label: "Latest version", value: "latest" },
];
export const getTargetFromMinOsVersion = (
minOsVersion: string
): AppleOSTarget => {
if (minOsVersion === LATEST_VERSION) return "latest";
return minOsVersion ? "custom" : "none";
};
interface IAppleOSTargetFormData {
target: AppleOSTarget;
minOsVersion: string;
deadline: string;
deadlineDays: string;
}
interface IAppleOSTargetFormErrors {
minOsVersion?: string;
deadline?: string;
deadlineDays?: string;
}
const validateMinVersion = (value: string) => {
@@ -38,17 +64,36 @@ const validateDeadline = (value: string) => {
return /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/.test(value);
};
/** Whole days only, and there's no upper bound. A deadline of zero days would
* leave no time to install the update, so 1 is the lowest meaningful value. */
const validateDeadlineDays = (value: string) => {
return /^[1-9]\d*$/.test(value);
};
const validateForm = (formData: IAppleOSTargetFormData) => {
const errors: IAppleOSTargetFormErrors = {};
// Both fields may be cleared out and saved
if (
!validatePresence(formData.minOsVersion) &&
!validatePresence(formData.deadline)
) {
// Nothing to validate: saving clears the version and the deadline.
if (formData.target === "none") {
return errors;
}
// Only the days field is shown in "latest" mode, so it's the only one that
// can be at fault — validating the hidden fields would block the save on an
// error the user can't see.
if (formData.target === "latest") {
if (!validatePresence(formData.deadlineDays)) {
errors.deadlineDays = "The days after release is required.";
} else if (!validateDeadlineDays(formData.deadlineDays)) {
errors.deadlineDays =
"Days after release must be a whole number of 1 or more.";
}
return errors;
}
// Both fields are required for a custom version: "No updates enforced" is
// how enforcement is turned off, so an empty form here isn't a way to clear
// the settings.
if (!validatePresence(formData.minOsVersion)) {
errors.minOsVersion = "The minimum version is required.";
} else if (!validateMinVersion(formData.minOsVersion)) {
@@ -70,34 +115,55 @@ const APPLE_PLATFORMS_TO_CONFIG_FIELDS = {
ipados: "ipados_updates",
};
interface IAppleOSUpdatesFields {
minimum_version: string;
deadline: string;
deadline_days: number | null;
update_new_hosts?: boolean;
}
interface IAppleUpdatesMdmConfigData {
mdm: {
macos_updates?: {
minimum_version: string;
deadline: string;
};
ipados_updates?: {
minimum_version: string;
deadline: string;
};
ios_updates?: {
minimum_version: string;
deadline: string;
};
macos_updates?: IAppleOSUpdatesFields;
ipados_updates?: IAppleOSUpdatesFields;
ios_updates?: IAppleOSUpdatesFields;
};
}
/** Every field is sent for every target, including nulls: the config PATCH
* merges field by field, so an omitted key leaves the stored value in place. */
const createAppleOSUpdatesData = (
applePlatform: ApplePlatform,
minOsVersion: string,
deadline: string,
updateNewHosts?: boolean
formData: IAppleOSTargetFormData,
updateNewHosts: boolean
): IAppleUpdatesMdmConfigData => {
const { target, minOsVersion, deadline, deadlineDays } = formData;
let fields: IAppleOSUpdatesFields;
switch (target) {
case "latest":
fields = {
minimum_version: LATEST_VERSION,
// A deadline can't coexist with "latest"; deadline_days replaces it.
deadline: "",
deadline_days: parseInt(deadlineDays, 10),
};
break;
case "custom":
fields = {
minimum_version: minOsVersion,
deadline,
deadline_days: null,
};
break;
default:
fields = { minimum_version: "", deadline: "", deadline_days: null };
}
return {
mdm: {
[APPLE_PLATFORMS_TO_CONFIG_FIELDS[applePlatform]]: {
minimum_version: minOsVersion,
deadline,
...fields,
// Add update_new_hosts only for macOS right now.
...(applePlatform === "darwin"
? { update_new_hosts: updateNewHosts }
@@ -112,6 +178,7 @@ interface IAppleOSTargetFormProps {
applePlatform: ApplePlatform;
defaultMinOsVersion: string;
defaultDeadline: string;
defaultDeadlineDays: string;
defaultUpdateNewHosts?: boolean;
refetchAppConfig: () => void;
refetchTeamConfig: () => void;
@@ -122,6 +189,7 @@ const AppleOSTargetForm = ({
applePlatform,
defaultMinOsVersion,
defaultDeadline,
defaultDeadlineDays,
defaultUpdateNewHosts,
refetchAppConfig,
refetchTeamConfig,
@@ -130,8 +198,15 @@ const AppleOSTargetForm = ({
.gitops_mode_enabled;
const [isSaving, setIsSaving] = useState(false);
const [minOsVersion, setMinOsVersion] = useState(defaultMinOsVersion);
const [target, setTarget] = useState<AppleOSTarget>(
getTargetFromMinOsVersion(defaultMinOsVersion)
);
const [minOsVersion, setMinOsVersion] = useState(
// The sentinel is a mode, not a version to show in the input.
defaultMinOsVersion === LATEST_VERSION ? "" : defaultMinOsVersion
);
const [deadline, setDeadline] = useState(defaultDeadline);
const [deadlineDays, setDeadlineDays] = useState(defaultDeadlineDays);
const [minOsVersionError, setMinOsVersionError] = useState<
string | undefined
>();
@@ -139,45 +214,70 @@ const AppleOSTargetForm = ({
defaultUpdateNewHosts || false
);
const [deadlineError, setDeadlineError] = useState<string | undefined>();
const [deadlineDaysError, setDeadlineDaysError] = useState<
string | undefined
>();
// "Latest version" always updates new hosts; the other targets leave it to
// the user. Derived rather than forced into state, and shared with the payload
// so what's saved matches what the checkbox shows.
const effectiveUpdateNewHosts = target === "latest" ? true : updateNewHosts;
// FIXME: This behaves unexpectedly when a user switches tabs or changes the teams dropdown while the form is
// submitting because this component is unmounted.
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const errors = validateForm({
minOsVersion,
deadline,
});
const formData = { target, minOsVersion, deadline, deadlineDays };
const errors = validateForm(formData);
setMinOsVersionError(errors.minOsVersion);
setDeadlineError(errors.deadline);
setDeadlineDaysError(errors.deadlineDays);
if (isEmpty(errors)) {
setIsSaving(true);
const updateData = createAppleOSUpdatesData(
applePlatform,
minOsVersion,
deadline,
updateNewHosts
formData,
effectiveUpdateNewHosts
);
try {
currentTeamId === APP_CONTEXT_NO_TEAM_ID
? await configAPI.update(updateData)
: await teamsAPI.update(updateData, currentTeamId);
notify.success("Successfully updated.");
// Only refetch on success: the refetch flips isFetching, which unmounts
// this form behind TargetSection's spinner and remounts it against the
// stored config -- that's what resets it. After a rejected save nothing
// changed on the server, so resetting would just discard what the user
// needs to fix.
currentTeamId === APP_CONTEXT_NO_TEAM_ID
? refetchAppConfig()
: refetchTeamConfig();
} catch (err) {
notify.error(getErrorMessage(err as AxiosResponse<IApiError>), {
response: err,
});
} finally {
currentTeamId === APP_CONTEXT_NO_TEAM_ID
? refetchAppConfig()
: refetchTeamConfig();
setIsSaving(false);
}
}
};
const handleTargetChange = (option: CustomOptionType | null) => {
if (!option) return;
setTarget(option.value as AppleOSTarget);
// "Latest version" forces the checkbox on, which isn't a choice the user
// made, so send it back to the persisted value rather than carrying the
// previous selection over. The text inputs keep whatever was typed.
setUpdateNewHosts(defaultUpdateNewHosts || false);
// The fields these belong to are about to unmount, and a stale message
// would reappear if the user came back to this target.
setMinOsVersionError(undefined);
setDeadlineError(undefined);
setDeadlineDaysError(undefined);
};
const handleMinVersionChange = (val: string) => {
setMinOsVersion(val);
};
@@ -192,44 +292,87 @@ const AppleOSTargetForm = ({
return (
<form className={baseClass} onSubmit={handleSubmit}>
<InputField
label="Minimum version"
name="minimum_version"
disabled={gitOpsModeEnabled}
tooltip={getMinimumVersionTooltip()}
<DropdownWrapper
label="Target"
name="target"
options={TARGET_OPTIONS}
value={target}
isDisabled={gitOpsModeEnabled}
onChange={handleTargetChange}
helpText={
<>
Use only versions{" "}
<CustomLink
text="available from Apple"
newTab
url="https://fleetdm.com/learn-more-about/apple-available-os-updates"
/>
</>
target === "latest" ? (
<>
Based on host hardware.{" "}
<CustomLink
text="Learn more"
newTab
url="https://fleetdm.com/learn-more-about/apple-available-os-updates"
/>
</>
) : undefined
}
value={minOsVersion}
error={minOsVersionError}
onChange={handleMinVersionChange}
/>
<InputField
disabled={gitOpsModeEnabled}
name="deadline"
label="Deadline"
tooltip="The end user can't dismiss the OS update once they reach this deadline. Deadline is 12:00 (Noon), the host's local time."
helpText="YYYY-MM-DD format only (e.g., “2024-07-01”)."
value={deadline}
error={deadlineError}
onChange={handleDeadlineChange}
/>
{target === "custom" && (
<>
<InputField
label="Minimum version"
name="minimum_version"
disabled={gitOpsModeEnabled}
tooltip={getMinimumVersionTooltip()}
helpText={
<>
Use only versions{" "}
<CustomLink
text="available from Apple."
newTab
url="https://fleetdm.com/learn-more-about/apple-available-os-updates"
/>
</>
}
value={minOsVersion}
error={minOsVersionError}
onChange={handleMinVersionChange}
/>
<InputField
disabled={gitOpsModeEnabled}
name="deadline"
label="Deadline"
tooltip="The end user can't dismiss the OS update once they reach this deadline. Deadline is 12:00 (Noon), the host's local time."
helpText="YYYY-MM-DD format only (e.g., “2024-07-01”)."
value={deadline}
error={deadlineError}
onChange={handleDeadlineChange}
/>
</>
)}
{target === "latest" && (
<InputField
disabled={gitOpsModeEnabled}
name="deadline_days"
label="Days after release"
// Deliberately a text input: a number input's native min/step would
// block submission before validateForm runs, so the user would get a
// browser tooltip instead of the form's own error styling.
helpText="Whole number of days, 1 or more."
tooltip="The number of days after Apple releases an update before hosts are required to install it."
value={deadlineDays}
error={deadlineDaysError}
onChange={setDeadlineDays}
/>
)}
{applePlatform === "darwin" && (
<Checkbox
name="update_new_hosts"
disabled={gitOpsModeEnabled}
// "Latest version" always updates new hosts, so the choice is made
// for the user rather than hidden from them.
disabled={gitOpsModeEnabled || target === "latest"}
onChange={setUpdateNewHosts}
value={updateNewHosts}
value={effectiveUpdateNewHosts}
className={`${baseClass}__checkbox`}
labelTooltipContent={
"During automated enrollment (ADE), hosts below the minimum version are updated to the latest version. If a minimum version isn't set, all hosts are updated to the latest version."
target === "latest"
? "During automated enrollment (ADE), all hosts will be updated to latest macOS version."
: "During automated enrollment (ADE), hosts below the minimum version are updated to the latest version. If a minimum version isn't set, all hosts are updated to the latest version."
}
>
Update new hosts to latest
@@ -0,0 +1,6 @@
.apple-os-target-form {
// The target dropdown, the fields it reveals, the checkbox and the save
// button are stacked siblings, so spacing belongs to the form rather than
// to each field.
@include vertical-form-layout;
}
@@ -0,0 +1,89 @@
import React from "react";
import { screen } from "@testing-library/react";
import { noop } from "lodash";
import { createCustomRenderer } from "test/test-utils";
import PlatformTabs from "./PlatformTabs";
const render = createCustomRenderer({ withBackendMock: true });
const defaultProps = {
currentTeamId: 1,
defaultMacOSVersion: "11.0",
defaultMacOSDeadline: "2024-12-31",
defaultMacOSDeadlineDays: "",
defaultMacOSUpdateNewHosts: true,
defaultIOSVersion: "17.5",
defaultIOSDeadline: "2024-12-31",
defaultIOSDeadlineDays: "",
defaultIPadOSVersion: "18.5",
defaultIPadOSDeadline: "2024-12-31",
defaultIPadOSDeadlineDays: "",
defaultWindowsDeadlineDays: "5",
defaultWindowsGracePeriodDays: "2",
onSelectPlatform: noop,
refetchAppConfig: noop,
refetchTeamConfig: noop,
isWindowsMdmEnabled: true,
isAndroidMdmEnabled: true,
};
describe("PlatformTabs", () => {
// Only the Apple forms offer a target to choose; Windows is always deadline
// driven and Android isn't supported yet. The tabs decide which form each
// platform gets, so the dropdown must not leak into the other two.
it("renders the target dropdown on the macOS tab", () => {
render(<PlatformTabs {...defaultProps} selectedPlatform="darwin" />);
expect(screen.getByLabelText(/Target/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Minimum version/i)).toBeInTheDocument();
});
it("renders the target dropdown on the iOS tab", () => {
render(<PlatformTabs {...defaultProps} selectedPlatform="ios" />);
expect(screen.getByLabelText(/Target/i)).toBeInTheDocument();
});
it("renders the target dropdown on the iPadOS tab", () => {
render(<PlatformTabs {...defaultProps} selectedPlatform="ipados" />);
expect(screen.getByLabelText(/Target/i)).toBeInTheDocument();
});
it("does not render the target dropdown on the Windows tab", () => {
render(<PlatformTabs {...defaultProps} selectedPlatform="windows" />);
expect(screen.queryByLabelText(/Target/i)).not.toBeInTheDocument();
// Windows fields don't associate their labels with the input, so match the
// label text rather than the control.
expect(screen.getByText(/Grace period/i)).toBeInTheDocument();
});
it("does not render the target dropdown on the Android tab", () => {
render(<PlatformTabs {...defaultProps} selectedPlatform="android" />);
expect(screen.queryByLabelText(/Target/i)).not.toBeInTheDocument();
expect(screen.getByText(/Android updates are coming soon/i)).toBeVisible();
});
it("hides the Windows and Android tabs when their MDM isn't enabled", () => {
render(
<PlatformTabs
{...defaultProps}
selectedPlatform="darwin"
isWindowsMdmEnabled={false}
isAndroidMdmEnabled={false}
/>
);
expect(screen.getByRole("tab", { name: /macOS/i })).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: /Windows/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: /Android/i })
).not.toBeInTheDocument();
});
});
@@ -19,11 +19,14 @@ interface IPlatformTabsProps {
currentTeamId: number;
defaultMacOSVersion: string;
defaultMacOSDeadline: string;
defaultMacOSDeadlineDays: string;
defaultMacOSUpdateNewHosts: boolean;
defaultIOSVersion: string;
defaultIOSDeadline: string;
defaultIOSDeadlineDays: string;
defaultIPadOSVersion: string;
defaultIPadOSDeadline: string;
defaultIPadOSDeadlineDays: string;
defaultWindowsDeadlineDays: string;
defaultWindowsGracePeriodDays: string;
selectedPlatform: OSUpdatesTargetPlatform;
@@ -37,11 +40,14 @@ interface IPlatformTabsProps {
const PlatformTabs = ({
currentTeamId,
defaultMacOSDeadline,
defaultMacOSDeadlineDays,
defaultMacOSVersion,
defaultMacOSUpdateNewHosts,
defaultIOSDeadline,
defaultIOSDeadlineDays,
defaultIOSVersion,
defaultIPadOSDeadline,
defaultIPadOSDeadlineDays,
defaultIPadOSVersion,
defaultWindowsDeadlineDays,
defaultWindowsGracePeriodDays,
@@ -108,6 +114,7 @@ const PlatformTabs = ({
applePlatform="darwin"
defaultMinOsVersion={defaultMacOSVersion}
defaultDeadline={defaultMacOSDeadline}
defaultDeadlineDays={defaultMacOSDeadlineDays}
defaultUpdateNewHosts={defaultMacOSUpdateNewHosts}
key={currentTeamId}
refetchAppConfig={refetchAppConfig}
@@ -142,6 +149,7 @@ const PlatformTabs = ({
applePlatform="ios"
defaultMinOsVersion={defaultIOSVersion}
defaultDeadline={defaultIOSDeadline}
defaultDeadlineDays={defaultIOSDeadlineDays}
key={currentTeamId}
refetchAppConfig={refetchAppConfig}
refetchTeamConfig={refetchTeamConfig}
@@ -158,6 +166,7 @@ const PlatformTabs = ({
applePlatform="ipados"
defaultMinOsVersion={defaultIPadOSVersion}
defaultDeadline={defaultIPadOSDeadline}
defaultDeadlineDays={defaultIPadOSDeadlineDays}
key={currentTeamId}
refetchAppConfig={refetchAppConfig}
refetchTeamConfig={refetchTeamConfig}
@@ -1,7 +1,7 @@
.platform-tabs {
&__tab-panel {
display: grid;
gap: $pad-medium;
gap: $pad-large; // match figma
grid-template-columns: 1fr 1fr;
grid-template-areas: "target nudge-preview";
@@ -27,9 +27,4 @@
}
}
}
.apple-os-target-form,
.windows-target-form {
padding-right: $pad-xlarge; // match figma
}
}
@@ -40,6 +40,29 @@ const getDefaultUpdateNewHosts = ({
}
};
/** deadline_days is only set in "latest" mode; an empty string means unset,
* matching how the version and deadline defaults are handled. */
const getDefaultAppleDeadlineDays = ({
osType,
currentTeamId,
appConfig,
teamConfig,
}: GetDefaultFnParams) => {
const mdmData =
currentTeamId === API_NO_TEAM_ID ? appConfig?.mdm : teamConfig?.mdm;
switch (osType) {
case "darwin":
return mdmData?.macos_updates.deadline_days?.toString() ?? "";
case "ios":
return mdmData?.ios_updates.deadline_days?.toString() ?? "";
case "ipados":
return mdmData?.ipados_updates.deadline_days?.toString() ?? "";
default:
return "";
}
};
const getDefaultOSVersion = ({
osType,
currentTeamId,
@@ -170,6 +193,24 @@ const TargetSection = ({
appConfig,
teamConfig,
});
const defaultMacOSDeadlineDays = getDefaultAppleDeadlineDays({
osType: "darwin",
currentTeamId,
appConfig,
teamConfig,
});
const defaultIOSDeadlineDays = getDefaultAppleDeadlineDays({
osType: "ios",
currentTeamId,
appConfig,
teamConfig,
});
const defaultIPadOSDeadlineDays = getDefaultAppleDeadlineDays({
osType: "ipados",
currentTeamId,
appConfig,
teamConfig,
});
const defaultMacOSUpdateNewHosts = getDefaultUpdateNewHosts({
osType: "darwin",
currentTeamId,
@@ -205,10 +246,13 @@ const TargetSection = ({
currentTeamId={currentTeamId}
defaultMacOSVersion={defaultMacOSVersion}
defaultMacOSDeadline={defaultMacOSDeadline}
defaultMacOSDeadlineDays={defaultMacOSDeadlineDays}
defaultIOSVersion={defaultIOSVersion}
defaultIOSDeadline={defaultIOSDeadline}
defaultIOSDeadlineDays={defaultIOSDeadlineDays}
defaultIPadOSVersion={defaultIPadOSOSVersion}
defaultIPadOSDeadline={defaultIPadOSDeadline}
defaultIPadOSDeadlineDays={defaultIPadOSDeadlineDays}
defaultWindowsDeadlineDays={defaultWindowsDeadlineDays}
defaultWindowsGracePeriodDays={defaultWindowsGracePeriodDays}
defaultMacOSUpdateNewHosts={defaultMacOSUpdateNewHosts}
@@ -177,7 +177,7 @@ const WindowsTargetForm = ({
<form className={baseClass} onSubmit={handleSubmit}>
<InputField
disabled={gitOpsModeEnabled}
label="Deadline"
label="Days after release"
tooltip="Number of days the end user has before updates are installed and the host is forced to restart."
helpText="Number of days from 0 to 30."
value={formData.deadlineDays}
@@ -0,0 +1,5 @@
.windows-target-form {
// Matches the Apple target form so the tabs are consistent: the fields and
// the save button are stacked siblings, spaced by the form.
@include vertical-form-layout;
}
@@ -654,19 +654,6 @@ const HostDetailsPage = ({
? teams?.find((t) => t.id === host.team_id)?.features
: config?.features;
const getOSVersionRequirementFromMDMConfig = (hostPlatform: string) => {
switch (hostPlatform) {
case "darwin":
return mdmConfig?.macos_updates;
case "ipados":
return mdmConfig?.ipados_updates;
case "ios":
return mdmConfig?.ios_updates;
default:
return undefined;
}
};
useEffect(() => {
setUsersState(() => {
return (
@@ -1540,9 +1527,8 @@ const HostDetailsPage = ({
vitalsData={vitalsData}
munki={macadmins?.munki}
mdm={host?.mdm}
osVersionRequirement={getOSVersionRequirementFromMDMConfig(
host.platform
)}
osUpdateMinimumVersion={host.os_update_minimum_version}
osUpdateDeadline={host.os_update_deadline}
toggleLocationModal={toggleLocationModal}
toggleMDMStatusModal={toggleMDMStatusModal}
toggleVitalsModal={toggleVitalsModal}
@@ -2087,9 +2073,8 @@ const HostDetailsPage = ({
vitalsData={vitalsData}
munki={macadmins?.munki}
mdm={host?.mdm}
osVersionRequirement={getOSVersionRequirementFromMDMConfig(
host.platform
)}
osUpdateMinimumVersion={host.os_update_minimum_version}
osUpdateDeadline={host.os_update_deadline}
toggleLocationModal={toggleLocationModal}
toggleMDMStatusModal={toggleMDMStatusModal}
customHostVitals={host.custom_host_vitals}
@@ -996,4 +996,72 @@ describe("Custom host vitals", () => {
expect(onEditCustomHostVital).toHaveBeenCalledWith(customHostVitals[0]);
});
describe("Operating system OS update requirement", () => {
const renderWithRequirement = createCustomRenderer({});
it("shows the required version and deadline", async () => {
const mockHost = createMockHost({
platform: "darwin",
os_version: "macOS 26.5",
});
const { user } = renderWithRequirement(
<Vitals
vitalsData={mockHost}
osUpdateMinimumVersion="26.6"
osUpdateDeadline="2026-07-30"
/>
);
await user.hover(screen.getByText("macOS 26.5"));
await waitFor(() => {
const tooltip = screen.getByText(/Minimum version required:/i);
expect(tooltip).toBeVisible();
expect(tooltip).toHaveTextContent("Minimum version required: 26.6");
expect(tooltip).toHaveTextContent("Deadline: 2026-07-30");
});
// The values are bolded, the labels aren't.
expect(screen.getByText("26.6").tagName).toBe("B");
expect(screen.getByText("2026-07-30").tagName).toBe("B");
});
it("shows Pending while the target is still being resolved", async () => {
const mockHost = createMockHost({
platform: "darwin",
os_version: "macOS 26.5",
});
const { user } = renderWithRequirement(
<Vitals
vitalsData={mockHost}
osUpdateMinimumVersion="Pending"
osUpdateDeadline="Pending"
/>
);
await user.hover(screen.getByText("macOS 26.5"));
await waitFor(() => {
const tooltip = screen.getByText(/Minimum version required:/i);
expect(tooltip).toBeVisible();
expect(tooltip).toHaveTextContent("Minimum version required: Pending");
expect(tooltip).toHaveTextContent("Deadline: Pending");
});
});
it("renders no tooltip when there's no requirement", () => {
const mockHost = createMockHost({
platform: "darwin",
os_version: "macOS 26.5",
});
renderWithRequirement(<Vitals vitalsData={mockHost} />);
expect(screen.getByText("macOS 26.5")).toBeVisible();
expect(screen.queryByText(/Minimum version required/i)).toBeNull();
});
});
});
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import classnames from "classnames";
import { IAppleDeviceUpdates } from "interfaces/config";
import { IHostCustomVital } from "interfaces/custom_host_vitals";
import { IHostMdmData, IMunkiData } from "interfaces/host";
import {
@@ -45,7 +44,12 @@ export interface IHostVitalsSources {
vitalsData: { [key: string]: any };
munki?: IMunkiData | null;
mdm?: IHostMdmData;
osVersionRequirement?: IAppleDeviceUpdates;
/** The OS version the host is required to reach, resolved per host by the
* server. "Pending" while Fleet is still working it out for a "latest"
* requirement, and undefined when OS updates aren't configured. */
osUpdateMinimumVersion?: string | null;
/** The deadline for osUpdateMinimumVersion, following the same states. */
osUpdateDeadline?: string | null;
/**
* Opens the Location modal. Presence of this handler also makes the
* Location row interactive omit it for read-only contexts (e.g., the
@@ -99,6 +103,10 @@ const getGridColumnCount = (grid: HTMLElement | null) => {
const baseClass = "vitals-card";
/** What the API sends for a host's OS update target while Fleet is still
* resolving a "latest" requirement. Shown to the user as-is, per the design. */
const OS_UPDATE_REQUIREMENT_PENDING = "Pending";
const DISK_ENCRYPTION_MESSAGES = {
darwin: {
enabled: (
@@ -166,7 +174,8 @@ export const buildHostVitals = ({
vitalsData,
munki,
mdm,
osVersionRequirement,
osUpdateMinimumVersion,
osUpdateDeadline,
toggleLocationModal,
toggleMDMStatusModal,
customHostVitals,
@@ -544,8 +553,8 @@ export const buildHostVitals = ({
}
// Operating system
// No tooltip if minimum version is not set, including all Windows, Linux, ChromeOS, Android operating systems
if (!osVersionRequirement?.minimum_version) {
// No tooltip if there's no requirement, including all Windows, Linux, ChromeOS, Android operating systems
if (!osUpdateMinimumVersion) {
const version = vitalsData.os_version;
const versionForRender = ROLLING_ARCH_LINUX_VERSIONS.includes(version) ? (
<>
@@ -567,11 +576,16 @@ export const buildHostVitals = ({
),
});
} else {
const osVersionWithoutPrefix = removeOSPrefix(vitalsData.os_version);
// A "latest" requirement is resolved per host by the server, which sends
// "Pending" until it has worked the target out. There's nothing to
// compare against until then, so no compliance icon is shown.
const isPendingRequirement =
osUpdateMinimumVersion === OS_UPDATE_REQUIREMENT_PENDING;
const osVersionRequirementMet =
isPendingRequirement ||
compareVersions(
osVersionWithoutPrefix,
osVersionRequirement.minimum_version
removeOSPrefix(vitalsData.os_version),
osUpdateMinimumVersion
) >= 0;
vitals.push({
@@ -588,21 +602,11 @@ export const buildHostVitals = ({
<TooltipWrapper
className={`${baseClass}__os-version-tooltip`}
tipContent={
osVersionRequirementMet ? (
<>
{vitalsData.os_version}
<br />
Meets minimum version requirement.
</>
) : (
<>
{vitalsData.os_version}
<br />
Does not meet minimum version requirement.
<br />
Deadline to update: {osVersionRequirement.deadline}
</>
)
<>
Minimum version required: <b>{osUpdateMinimumVersion}</b>
<br />
Deadline: <b>{osUpdateDeadline}</b>
</>
}
>
<span className={`${baseClass}__os-version-text`}>
@@ -729,7 +733,8 @@ const Vitals = ({
vitalsData,
munki,
mdm,
osVersionRequirement,
osUpdateMinimumVersion,
osUpdateDeadline,
className,
toggleLocationModal,
toggleMDMStatusModal,
@@ -760,7 +765,8 @@ const Vitals = ({
vitalsData,
munki,
mdm,
osVersionRequirement,
osUpdateMinimumVersion,
osUpdateDeadline,
toggleLocationModal,
toggleMDMStatusModal,
customHostVitals,
@@ -78,6 +78,13 @@
}
}
// TooltipWrapper pulls its dashed underline into the element's last pixel
// with a negative margin, which DataSet's `dd` then clips away. Keep the
// border inside the box so the hover affordance survives on every vital.
.component__tooltip-wrapper__underline {
margin-bottom: 0;
}
&__os-version-tooltip {
min-width: 0;