From e9ee26e70da80e5bab074902fc25b9e0ae267e62 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:26:39 -0500 Subject: [PATCH] Added Android cert activity logging (#42608) **Related issue:** Resolves #37546 Noah approved design here: https://fleetdm.slack.com/archives/C09HG9VMRSS/p1774895365069069?thread_ts=1774647818.613419&cid=C09HG9VMRSS Docs: https://github.com/fleetdm/fleet/pull/42609 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Certificate installation activity logging for Android hosts (success & failure) * "Installed certificate" activity type displayed in host activity feed with status * Host activity card enabled on Android host details page to show installs * Detailed install modal with error detail reveal when failures occur * Upcoming activities tab disabled for Android hosts where unsupported --- ...37546-android-certificate-install-activity | 2 + .../CertificateInstallDetailsModal.tsx | 74 +++++++++ .../_styles.scss | 13 ++ .../CertificateInstallDetailsModal/index.ts | 2 + frontend/interfaces/activity.ts | 7 +- .../HostDetailsPage/HostDetailsPage.tsx | 143 ++++++++++-------- .../hosts/details/cards/Activity/Activity.tsx | 17 ++- .../details/cards/Activity/ActivityConfig.tsx | 4 + .../InstalledCertificateActivityItem.tsx | 38 +++++ .../InstalledCertificateActivityItem/index.ts | 1 + .../ResentCertificateActivityItem.tsx | 25 +++ .../ResentCertificateActivityItem/index.ts | 1 + server/fleet/activities.go | 26 ++++ server/fleet/certificate_templates.go | 8 + server/service/certificates.go | 36 ++++- 15 files changed, 329 insertions(+), 68 deletions(-) create mode 100644 changes/37546-android-certificate-install-activity create mode 100644 frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/CertificateInstallDetailsModal.tsx create mode 100644 frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/_styles.scss create mode 100644 frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/index.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/InstalledCertificateActivityItem.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/index.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/ActivityItems/ResentCertificateActivityItem/ResentCertificateActivityItem.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/ActivityItems/ResentCertificateActivityItem/index.ts diff --git a/changes/37546-android-certificate-install-activity b/changes/37546-android-certificate-install-activity new file mode 100644 index 0000000000..04ad713524 --- /dev/null +++ b/changes/37546-android-certificate-install-activity @@ -0,0 +1,2 @@ +* Added activity logging when a certificate is installed or fails to install on an Android host. +* Enabled the host activity card on the Android host details page. diff --git a/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/CertificateInstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/CertificateInstallDetailsModal.tsx new file mode 100644 index 0000000000..a2b0495fa6 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/CertificateInstallDetailsModal.tsx @@ -0,0 +1,74 @@ +import React, { useState } from "react"; + +import Modal from "components/Modal"; +import ModalFooter from "components/ModalFooter"; +import Button from "components/buttons/Button"; +import IconStatusMessage from "components/IconStatusMessage"; +import Textarea from "components/Textarea"; +import RevealButton from "components/buttons/RevealButton"; + +const baseClass = "certificate-install-details-modal"; + +export interface ICertificateInstallDetails { + certificateName: string; + hostDisplayName: string; + status: string; + detail: string; +} + +interface ICertificateInstallDetailsModalProps { + details: ICertificateInstallDetails; + onCancel: () => void; +} + +const CertificateInstallDetailsModal = ({ + details, + onCancel, +}: ICertificateInstallDetailsModalProps) => { + const { certificateName, hostDisplayName, detail } = details; + + const [showDetails, setShowDetails] = useState(false); + + const formattedHost = hostDisplayName ? {hostDisplayName} : "the host"; + + return ( + +
+ + Fleet failed to install certificate {certificateName} on{" "} + {formattedHost}. + + } + /> + {detail && ( + <> + setShowDetails((prev) => !prev)} + /> + {showDetails && ( + + )} + + )} +
+ Done} /> +
+ ); +}; + +export default CertificateInstallDetailsModal; diff --git a/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/_styles.scss new file mode 100644 index 0000000000..c179f6a447 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/_styles.scss @@ -0,0 +1,13 @@ +.certificate-install-details-modal { + overflow-wrap: anywhere; + + &__modal-content { + display: flex; + flex-direction: column; + gap: $pad-medium; + } + + .reveal-button { + width: min-content; + } +} diff --git a/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/index.ts b/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/index.ts new file mode 100644 index 0000000000..d42661a593 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal/index.ts @@ -0,0 +1,2 @@ +export { default } from "./CertificateInstallDetailsModal"; +export type { ICertificateInstallDetails } from "./CertificateInstallDetailsModal"; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 110432f58a..dbfd7aa0fd 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -160,6 +160,7 @@ export enum ActivityType { EditedHostIdpData = "edited_host_idp_data", AddedCertificate = "added_certificate", DeletedCertificate = "deleted_certificate", + InstalledCertificate = "installed_certificate", EditedEnrollSecrets = "edited_enroll_secrets", AddedMicrosoftEntraTenant = "added_microsoft_entra_tenant", DeletedMicrosoftEntraTenant = "deleted_microsoft_entra_tenant", @@ -181,7 +182,9 @@ export type IHostPastActivityType = | ActivityType.CanceledRunScript | ActivityType.CanceledInstallAppStoreApp | ActivityType.CanceledInstallSoftware - | ActivityType.CanceledUninstallSoftware; + | ActivityType.CanceledUninstallSoftware + | ActivityType.InstalledCertificate + | ActivityType.ResentCertificate; /** This is a subset of ActivityType that are shown only for the host upcoming activities */ export type IHostUpcomingActivityType = @@ -289,6 +292,7 @@ export interface IActivityDetails { tenant_id?: string; certificate_name?: string; certificate_template_id?: number; + detail?: string; } // maps activity types to their corresponding label to use when filtering activites via the dropdown @@ -459,5 +463,6 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { [ActivityType.EditedHostIdpData]: "Edited host identity provider (IdP) data", [ActivityType.AddedCertificate]: "Added certificate", [ActivityType.DeletedCertificate]: "Deleted certificate", + [ActivityType.InstalledCertificate]: "Installed certificate", [ActivityType.EditedEnrollSecrets]: "Edited enroll secrets", }; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 2a0eaa6d2d..a3ca0b32df 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -94,6 +94,9 @@ import SoftwareUninstallDetailsModal, { ISWUninstallDetailsParentState, } from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal"; import { IShowActivityDetailsData } from "components/ActivityItem/ActivityItem"; +import CertificateInstallDetailsModal, { + ICertificateInstallDetails, +} from "components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal"; import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; import CommandResultsModal from "pages/hosts/components/CommandDetailsModal"; @@ -268,6 +271,10 @@ const HostDetailsPage = ({ activityVPPInstallDetails, setActivityVPPInstallDetails, ] = useState(null); + const [ + certificateInstallDetails, + setCertificateInstallDetails, + ] = useState(null); const [mdmCommandDetails, setMdmCommandDetails] = useState( null ); @@ -842,6 +849,15 @@ const HostDetailsPage = ({ platform: details?.host_platform || host?.platform, }); break; + case "installed_certificate": + setCertificateInstallDetails({ + certificateName: details?.certificate_name || "", + hostDisplayName: + host?.display_name || details?.host_display_name || "", + status: details?.status || "", + detail: details?.detail || "", + }); + break; default: // do nothing } }, @@ -1151,7 +1167,6 @@ const HostDetailsPage = ({ const showSoftwareLibraryTab = isPremiumTier; const showReportsTab = mdm?.enrollment_status !== "Pending"; - const showActivityCard = !isAndroidHost; const showAgentOptionsCard = !isIosOrIpadosHost && !isAndroidHost; const showLocalUserAccountsCard = !isIosOrIpadosHost && !isAndroidHost; const showCertificatesCard = @@ -1354,64 +1369,62 @@ const HostDetailsPage = ({ toggleLocationModal={toggleLocationModal} toggleMDMStatusModal={toggleMDMStatusModal} /> - {showActivityCard && ( - { - setActivityPage(0); - setShowMDMCommands(true); - }} - onHideMDMCommands={() => { - setActivityPage(0); - setShowMDMCommands(false); - }} - upcomingCount={ - (upcomingActivities?.count || 0) + - (upcomingMDMCommands?.count || 0) - } - onChangeTab={onChangeActivityTab} - onNextPage={() => setActivityPage(activityPage + 1)} - onPreviousPage={() => setActivityPage(activityPage - 1)} - onShowDetails={onShowActivityDetails} - onShowCommandDetails={setMdmCommandDetails} - onCancel={onCancelActivity} - /> - )} + { + setActivityPage(0); + setShowMDMCommands(true); + }} + onHideMDMCommands={() => { + setActivityPage(0); + setShowMDMCommands(false); + }} + upcomingCount={ + (upcomingActivities?.count || 0) + + (upcomingMDMCommands?.count || 0) + } + onChangeTab={onChangeActivityTab} + onNextPage={() => setActivityPage(activityPage + 1)} + onPreviousPage={() => setActivityPage(activityPage - 1)} + onShowDetails={onShowActivityDetails} + onShowCommandDetails={setMdmCommandDetails} + onCancel={onCancelActivity} + /> @@ -1651,6 +1660,12 @@ const HostDetailsPage = ({ onCancel={onCancelVppInstallDetailsModal} /> )} + {!!certificateInstallDetails && ( + setCertificateInstallDetails(null)} + /> + )} {!!mdmCommandDetails && ( void; onNextPage: () => void; onPreviousPage: () => void; @@ -79,6 +81,7 @@ const Activity = ({ className, upcomingCount, canCancelActivities, + isUpcomingDisabled = false, onChangeTab, onNextPage, onPreviousPage, @@ -116,8 +119,18 @@ const Activity = ({ Past - - Upcoming + + {isUpcomingDisabled ? ( + + Upcoming + + ) : ( + Upcoming + )} diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx index 1c4517d54a..aba94816bb 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx @@ -22,6 +22,8 @@ import InstalledSoftwareActivityItem from "./ActivityItems/InstalledSoftwareActi import CanceledRunScriptActivityItem from "./ActivityItems/CanceledRunScriptActivityItem"; import CanceledInstallSoftwareActivityItem from "./ActivityItems/CanceledInstallSoftwareActivityItem"; import CanceledUninstallSoftwareActivtyItem from "./ActivityItems/CanceledUninstallSoftwareActivtyItem"; +import InstalledCertificateActivityItem from "./ActivityItems/InstalledCertificateActivityItem"; +import ResentCertificateActivityItem from "./ActivityItems/ResentCertificateActivityItem"; /** The component props that all host activity items must adhere to */ export interface IHostActivityItemComponentProps { @@ -64,6 +66,8 @@ export const pastActivityComponentMap: Record< [ActivityType.CanceledInstallSoftware]: CanceledInstallSoftwareActivityItem, [ActivityType.CanceledInstallAppStoreApp]: CanceledInstallSoftwareActivityItem, [ActivityType.CanceledUninstallSoftware]: CanceledUninstallSoftwareActivtyItem, + [ActivityType.InstalledCertificate]: InstalledCertificateActivityItem, + [ActivityType.ResentCertificate]: ResentCertificateActivityItem, }; export const upcomingActivityComponentMap: Record< diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/InstalledCertificateActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/InstalledCertificateActivityItem.tsx new file mode 100644 index 0000000000..916e1cd85c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/InstalledCertificateActivityItem.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +import ActivityItem from "components/ActivityItem"; + +import { IHostActivityItemComponentPropsWithShowDetails } from "../../ActivityConfig"; + +const baseClass = "installed-certificate-activity-item"; + +const InstalledCertificateActivityItem = ({ + activity, + onShowDetails, +}: IHostActivityItemComponentPropsWithShowDetails) => { + const isFailed = activity.details?.status === "failed_install"; + + return ( + + {isFailed ? ( + <> + Fleet failed to install certificate{" "} + {activity.details?.certificate_name} on this host. + + ) : ( + <> + Fleet installed certificate{" "} + {activity.details?.certificate_name} on this host. + + )} + + ); +}; + +export default InstalledCertificateActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/index.ts new file mode 100644 index 0000000000..90f291e28a --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./InstalledCertificateActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/ResentCertificateActivityItem/ResentCertificateActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/ResentCertificateActivityItem/ResentCertificateActivityItem.tsx new file mode 100644 index 0000000000..6f53f33572 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/ResentCertificateActivityItem/ResentCertificateActivityItem.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +import ActivityItem from "components/ActivityItem"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; + +const baseClass = "resent-certificate-activity-item"; + +const ResentCertificateActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + return ( + + {activity.actor_full_name} resent{" "} + {activity.details?.certificate_name} certificate on this host. + + ); +}; + +export default ResentCertificateActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/ResentCertificateActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/ResentCertificateActivityItem/index.ts new file mode 100644 index 0000000000..2d51bcb3a6 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/ResentCertificateActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./ResentCertificateActivityItem"; diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 07cfbfc05c..0643a6fb9a 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -167,6 +167,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeEditedAndroidProfile{}, ActivityTypeEditedAndroidCertificate{}, ActivityTypeResentCertificate{}, + ActivityTypeInstalledCertificate{}, ActivityTypeResentConfigurationProfile{}, ActivityTypeResentConfigurationProfileBatch{}, @@ -1826,3 +1827,28 @@ type ActivityTypeEditedEnrollSecrets struct { func (a ActivityTypeEditedEnrollSecrets) ActivityName() string { return "edited_enroll_secrets" } + +type ActivityTypeInstalledCertificate struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + CertificateTemplateID uint `json:"certificate_template_id"` + CertificateName string `json:"certificate_name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` +} + +func (a ActivityTypeInstalledCertificate) ActivityName() string { + return "installed_certificate" +} + +func (a ActivityTypeInstalledCertificate) HostIDs() []uint { + return []uint{a.HostID} +} + +func (a ActivityTypeInstalledCertificate) WasFromAutomation() bool { + return true +} + +func (a ActivityTypeInstalledCertificate) HostOnly() bool { + return true +} diff --git a/server/fleet/certificate_templates.go b/server/fleet/certificate_templates.go index 2b59d4cd06..8d90a46a13 100644 --- a/server/fleet/certificate_templates.go +++ b/server/fleet/certificate_templates.go @@ -55,6 +55,14 @@ var ( CertificateTemplateVerified CertificateTemplateStatus = "verified" ) +// CertificateActivityStatus represents the status of a certificate install activity. +type CertificateActivityStatus string + +const ( + CertificateActivityInstalled CertificateActivityStatus = "installed" + CertificateActivityFailedInstall CertificateActivityStatus = "failed_install" +) + // CertificateTemplateStatusToMDMDeliveryStatus converts a CertificateTemplateStatus to MDMDeliveryStatus. // This is used when converting HostCertificateTemplate to HostMDMProfile for the GetHost endpoint. func CertificateTemplateStatusToMDMDeliveryStatus(s CertificateTemplateStatus) MDMDeliveryStatus { diff --git a/server/service/certificates.go b/server/service/certificates.go index a88aae7ce6..5b375c283d 100644 --- a/server/service/certificates.go +++ b/server/service/certificates.go @@ -695,7 +695,41 @@ func (svc *Service) UpdateCertificateStatus(ctx context.Context, update *fleet.C // Fill in HostUUID from context update.HostUUID = host.UUID - return svc.ds.UpsertCertificateStatus(ctx, update) + if err := svc.ds.UpsertCertificateStatus(ctx, update); err != nil { + return err + } + + // Log activity for terminal install statuses only (not removals). + if update.OperationType == fleet.MDMOperationTypeInstall { + var actStatus fleet.CertificateActivityStatus + switch update.Status { + case fleet.MDMDeliveryVerified: + actStatus = fleet.CertificateActivityInstalled + case fleet.MDMDeliveryFailed: + actStatus = fleet.CertificateActivityFailedInstall + } + if actStatus != "" { + detail := "" + if update.Detail != nil { + detail = *update.Detail + } + if err := svc.NewActivity(ctx, nil, fleet.ActivityTypeInstalledCertificate{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + CertificateTemplateID: update.CertificateTemplateID, + CertificateName: record.Name, + Status: string(actStatus), + Detail: detail, + }); err != nil { + // Log and continue since we don't want the client to retry. + svc.logger.ErrorContext(ctx, "failed to create certificate install activity", "host.id", host.ID, "activity.status", actStatus, + "err", err) + ctxerr.Handle(ctx, err) + } + } + } + + return nil } ////////////////////////////////////////////////////////////////////////////////