Add frontend support for enrollment profile renewal failed activity (#44530)

Backend PR: #44511
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41422 

<img width="618" height="244" alt="image"
src="https://github.com/user-attachments/assets/c223e37d-7051-46a6-a2ea-6bd1bdcbb53e"
/>

<img width="777" height="780" alt="image"
src="https://github.com/user-attachments/assets/3b9ef4e9-2181-406b-a22e-e6773eba67af"
/>

<img width="649" height="236" alt="image"
src="https://github.com/user-attachments/assets/3985faf0-a1e4-404a-b190-cb623f52339a"
/>

<img width="1083" height="768" alt="image"
src="https://github.com/user-attachments/assets/2d4df607-4b34-435c-88db-6dc0fa09db2e"
/>


# Checklist for submitter

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

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information. Part of backend PR

- [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.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing
- [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 "Enrollment profile renewal failed" activity type and label.
* Failure entries now appear in activity feeds and host details with a
dedicated activity item and a details flow.
* Users can open a failure details modal showing a status icon, host
name (with fallback), relative failure time, guidance about certificate
expiration, and a link to Fleet support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Magnus Jensen
2026-05-01 12:05:31 -06:00
committed by GitHub
parent aea18a3157
commit 0f9df40a82
11 changed files with 217 additions and 31 deletions
@@ -0,0 +1,68 @@
import React from "react";
import { ICommandResult } from "interfaces/command";
import CommandResultsModal, {
GetIconName,
} from "pages/hosts/components/CommandDetailsModal";
import { formatDistanceToNow } from "date-fns";
import IconStatusMessage from "components/IconStatusMessage";
import CustomLink from "components/CustomLink";
export interface IFailedEnrollmentProfileModalProps {
command: { command_uuid: string };
onDone: () => void;
}
const failedEnrollmentProfileContentBody = (
baseClass: string,
result: ICommandResult
) => {
const displayTime = result.updated_at
? ` (${formatDistanceToNow(new Date(result.updated_at), {
includeSeconds: true,
addSuffix: true,
})})`
: null;
const hostDisplayName = result.hostname || "this host";
const messageText = (
<span>
Fleet enrollment profile renewal failed for <b>{hostDisplayName}</b>
{displayTime}.
</span>
);
return (
<div>
<IconStatusMessage
className={`${baseClass}__status-message`}
iconName={GetIconName(result.status)}
message={messageText}
/>
<p>
This profile contains a certificate that will expire. If the profile
isn&apos;t renewed before expiration, the host must be re-enrolled. For
assistance, reach out to{" "}
<CustomLink
text="Fleet support"
url="https://fleetdm.com/support"
newTab
/>
.
</p>
</div>
);
};
const FailedEnrollmentProfileModal = ({
command,
onDone,
}: IFailedEnrollmentProfileModalProps) => {
return (
<CommandResultsModal
command={command}
onDone={onDone}
title="Enrollment profile renewal details"
contentBody={failedEnrollmentProfileContentBody}
/>
);
};
export default FailedEnrollmentProfileModal;
@@ -0,0 +1,2 @@
export { default } from "./FailedEnrollmentProfileModal";
export type { IFailedEnrollmentProfileModalProps } from "./FailedEnrollmentProfileModal";
+5 -1
View File
@@ -169,6 +169,7 @@ export enum ActivityType {
DisabledManagedLocalAccount = "disabled_managed_local_account",
ViewedManagedLocalAccount = "read_managed_local_account",
CreatedManagedLocalAccount = "created_managed_local_account",
FailedEnrollmentProfileRenewal = "failed_enrollment_profile_renewal",
CreatedLabel = "created_label",
EditedLabel = "edited_label",
DeletedLabel = "deleted_label",
@@ -197,7 +198,8 @@ export type IHostPastActivityType =
| ActivityType.ResentCertificate
| ActivityType.ClearedPasscode
| ActivityType.ViewedManagedLocalAccount
| ActivityType.CreatedManagedLocalAccount;
| ActivityType.CreatedManagedLocalAccount
| ActivityType.FailedEnrollmentProfileRenewal;
/** This is a subset of ActivityType that are shown only for the host upcoming activities */
export type IHostUpcomingActivityType =
@@ -492,6 +494,8 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record<ActivityType, string> = {
"Turned off managed local account",
[ActivityType.ViewedManagedLocalAccount]: "Viewed managed account",
[ActivityType.CreatedManagedLocalAccount]: "Created managed account",
[ActivityType.FailedEnrollmentProfileRenewal]:
"Enrollment profile renewal failed",
[ActivityType.CreatedLabel]: "Created label",
[ActivityType.EditedLabel]: "Edited label",
[ActivityType.DeletedLabel]: "Deleted label",
@@ -34,6 +34,9 @@ import SoftwareUninstallDetailsModal, {
} from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal";
import { IShowActivityDetailsData } from "components/ActivityItem/ActivityItem";
import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers";
import FailedEnrollmentProfileModal, {
IFailedEnrollmentProfileModalProps,
} from "components/modals/FailedEnrollmentProfileModal";
import GlobalActivityItem from "./GlobalActivityItem";
import ActivityAutomationDetailsModal from "./components/ActivityAutomationDetailsModal";
@@ -135,6 +138,10 @@ const ActivityFeed = ({
appStoreDetails,
setAppStoreDetails,
] = useState<IActivityDetails | null>(null);
const [
enrollmentProfileFailedDetails,
setEnrollmentProfileFailedDetails,
] = useState<Omit<IFailedEnrollmentProfileModalProps, "onDone"> | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [createdAtDirection, setCreatedAtDirection] = useState("desc");
@@ -285,6 +292,13 @@ const ActivityFeed = ({
)
);
break;
case ActivityType.FailedEnrollmentProfileRenewal:
setEnrollmentProfileFailedDetails({
command: {
command_uuid: details?.command_uuid || "",
},
});
break;
default:
break;
}
@@ -440,6 +454,12 @@ const ActivityFeed = ({
onCancel={() => setAppStoreDetails(null)}
/>
)}
{enrollmentProfileFailedDetails && (
<FailedEnrollmentProfileModal
command={enrollmentProfileFailedDetails.command}
onDone={() => setEnrollmentProfileFailedDetails(null)}
/>
)}
</div>
);
};
@@ -40,6 +40,7 @@ const ACTIVITIES_WITH_DETAILS = new Set([
ActivityType.InstalledAppStoreApp,
ActivityType.RanScriptBatch,
ActivityType.CanceledScriptBatch,
ActivityType.FailedEnrollmentProfileRenewal,
]);
const getProfilesPlatformDisplayName = (
@@ -1939,6 +1940,14 @@ const TAGGED_TEMPLATES = {
</>
);
},
failedEnrollmentRenewalProfile: (activity: IActivity) => {
return (
<>
enrollment profile renewal failed for{" "}
<b>{activity.details?.host_display_name}</b>.
</>
);
},
};
const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
@@ -2376,6 +2385,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
case ActivityType.ClearedPasscode: {
return TAGGED_TEMPLATES.clearedPasscode(activity);
}
case ActivityType.FailedEnrollmentProfileRenewal: {
return TAGGED_TEMPLATES.failedEnrollmentRenewalProfile(activity);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}
@@ -4,7 +4,7 @@ import { formatDistanceToNow } from "date-fns";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { ICommand, ICommandResult } from "interfaces/command";
import { ICommandResult } from "interfaces/command";
import commandApi, {
IGetCommandResultsResponse,
@@ -22,7 +22,7 @@ import Button from "components/buttons/Button";
const baseClass = "command-details-modal";
const getIconName = (status: string): IconNames => {
export const GetIconName = (status: string): IconNames => {
switch (status) {
case "Error":
return "error";
@@ -101,14 +101,24 @@ const getStatusMessage = (result: ICommandResult): React.ReactNode => {
}
};
const defaultModalContentBody = (baseclass: string, result: ICommandResult) => (
<IconStatusMessage
className={`${baseclass}__status-message`}
iconName={GetIconName(result.status)}
message={getStatusMessage(result)}
/>
);
const ModalContent = ({
data,
isLoading,
error,
contentBody = defaultModalContentBody,
}: {
data: IGetCommandResultsResponse | undefined;
isLoading: boolean;
error: Error | null;
contentBody?: (baseClass: string, result: ICommandResult) => React.ReactNode;
}) => {
if (isLoading) {
return <Spinner />;
@@ -136,11 +146,7 @@ const ModalContent = ({
return (
<div className={`${baseClass}__modal-content`}>
<IconStatusMessage
className={`${baseClass}__status-message`}
iconName={getIconName(result.status)}
message={getStatusMessage(result)}
/>
{contentBody(baseClass, result)}
{!!result.payload && (
<InputField
type="textarea"
@@ -167,13 +173,24 @@ const ModalContent = ({
);
};
type ICommandResultsModalCommand = {
host_uuid?: string;
command_uuid: string;
};
interface ICommandResultsModalProps {
command: ICommand;
command: ICommandResultsModalCommand;
// contentBody if provided will be used to render content above the request and response payloads.
// if not defined, a default contentBody will be used to display a status message and icon based on profile status
contentBody?: (baseClass: string, result: ICommandResult) => React.ReactNode;
title?: string;
onDone: () => void;
}
const CommandResultsModal = ({
command: { host_uuid: host_identifier, command_uuid },
contentBody,
title = "MDM command details",
onDone,
}: ICommandResultsModalProps) => {
const { data, isLoading, error } = useQuery<
@@ -182,21 +199,32 @@ const CommandResultsModal = ({
IGetCommandResultsResponse,
IGetHostCommandResultsQueryKey[]
>(
[{ scope: "command_results", host_identifier, command_uuid }],
({ queryKey }) =>
commandApi.getHostCommandResults(queryKey[0]).then((resp) => {
if (!resp?.results) {
// this should not happen, but just in case return the response as is
return resp;
}
return {
results: resp.results.map?.((r) => ({
...r,
payload: atob(r.payload),
result: atob(r.result),
})),
};
}),
[
{
scope: "command_results",
host_identifier: host_identifier ?? "",
command_uuid,
},
],
async ({ queryKey }) => {
const resp =
queryKey[0].host_identifier === ""
? // if host_identifier is not provided, use the getCommandResults endpoint which does not require host_identifier
await commandApi.getCommandResults(queryKey[0].command_uuid)
: await commandApi.getHostCommandResults(queryKey[0]);
if (!resp?.results) {
// this should not happen, but just in case return the response as is
return resp;
}
return {
results: resp.results.map?.((r) => ({
...r,
payload: atob(r.payload),
result: atob(r.result),
})),
};
},
{
...DEFAULT_USE_QUERY_OPTIONS,
keepPreviousData: true,
@@ -205,13 +233,13 @@ const CommandResultsModal = ({
);
return (
<Modal
className={baseClass}
width="large"
title="MDM command details"
onExit={onDone}
>
<ModalContent data={data} isLoading={isLoading} error={error} />
<Modal className={baseClass} width="large" title={title} onExit={onDone}>
<ModalContent
data={data}
isLoading={isLoading}
error={error}
contentBody={contentBody}
/>
<ModalFooter primaryButtons={<Button onClick={onDone}>Close</Button>} />
</Modal>
);
@@ -1 +1,2 @@
export { default } from "./CommandDetailsModal";
export { GetIconName } from "./CommandDetailsModal";
@@ -95,6 +95,9 @@ import CertificateInstallDetailsModal, {
import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers";
import CommandResultsModal from "pages/hosts/components/CommandDetailsModal";
import FailedEnrollmentProfileModal, {
IFailedEnrollmentProfileModalProps,
} from "components/modals/FailedEnrollmentProfileModal";
import HostSummaryCard from "../cards/HostSummary";
import VitalsCard from "../cards/Vitals";
@@ -286,6 +289,10 @@ const HostDetailsPage = ({
const [mdmCommandDetails, setMdmCommandDetails] = useState<ICommand | null>(
null
);
const [
enrollmentProfileFailedDetails,
setEnrollmentProfileFailedDetails,
] = useState<Omit<IFailedEnrollmentProfileModalProps, "onDone"> | null>(null);
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
@@ -870,6 +877,13 @@ const HostDetailsPage = ({
detail: details?.detail || "",
});
break;
case ActivityType.FailedEnrollmentProfileRenewal:
setEnrollmentProfileFailedDetails({
command: {
command_uuid: details?.command_uuid || "",
},
});
break;
default: // do nothing
}
},
@@ -1715,6 +1729,12 @@ const HostDetailsPage = ({
onDone={onCancelMdmCommandDetailsModal}
/>
)}
{enrollmentProfileFailedDetails && (
<FailedEnrollmentProfileModal
command={enrollmentProfileFailedDetails.command}
onDone={() => setEnrollmentProfileFailedDetails(null)}
/>
)}
{showLockHostModal && (
<LockModal
id={host.id}
@@ -29,6 +29,7 @@ import ClearedPasscodeActivityItem from "./ActivityItems/ClearedPasscodeActivity
import FailedWipeActivityItem from "./ActivityItems/FailedWipeActivityItem";
import ViewedManagedLocalAccountActivityItem from "./ActivityItems/ViewedManagedLocalAccountActivityItem/ViewedManagedLocalAccountActivityItem";
import CreatedManagedLocalAccountActivityItem from "./ActivityItems/CreatedManagedLocalAccountActivityItem/CreatedManagedLocalAccountActivityItem";
import FailedEnrollmentProfileRenewalActivityItem from "./ActivityItems/FailedEnrollmentProfileRenewalActivityItem";
/** The component props that all host activity items must adhere to */
export interface IHostActivityItemComponentProps {
@@ -78,6 +79,7 @@ export const pastActivityComponentMap: Record<
[ActivityType.ClearedPasscode]: ClearedPasscodeActivityItem,
[ActivityType.ViewedManagedLocalAccount]: ViewedManagedLocalAccountActivityItem,
[ActivityType.CreatedManagedLocalAccount]: CreatedManagedLocalAccountActivityItem,
[ActivityType.FailedEnrollmentProfileRenewal]: FailedEnrollmentProfileRenewalActivityItem,
};
export const upcomingActivityComponentMap: Record<
@@ -0,0 +1,28 @@
import React from "react";
import ActivityItem from "components/ActivityItem";
import { IHostActivityItemComponentPropsWithShowDetails } from "../../ActivityConfig";
const baseClass = "failed-enrollment-profile-renewal-activity-item";
const FailedEnrollmentProfileRenewalActivityItem = ({
activity,
onShowDetails,
isSoloActivity,
}: IHostActivityItemComponentPropsWithShowDetails) => {
return (
<ActivityItem
className={baseClass}
activity={activity}
onShowDetails={onShowDetails}
isSoloActivity={isSoloActivity}
hideCancel
>
<span>
<b>Fleet</b> enrollment profile renewal failed for this host.{" "}
</span>
</ActivityItem>
);
};
export default FailedEnrollmentProfileRenewalActivityItem;
@@ -0,0 +1 @@
export { default } from "./FailedEnrollmentProfileRenewalActivityItem";