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:
+68
@@ -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'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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+12
@@ -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<
|
||||
|
||||
+28
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./FailedEnrollmentProfileRenewalActivityItem";
|
||||
Reference in New Issue
Block a user