Added Android cert activity logging (#42608)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-03-31 16:26:39 -05:00
committed by GitHub
parent aedf366fc0
commit e9ee26e70d
15 changed files with 329 additions and 68 deletions
@@ -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.
@@ -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 ? <b>{hostDisplayName}</b> : "the host";
return (
<Modal
title="Install details"
onExit={onCancel}
onEnter={onCancel}
className={baseClass}
>
<div className={`${baseClass}__modal-content`}>
<IconStatusMessage
className={`${baseClass}__status-message`}
iconName="error"
message={
<span>
Fleet failed to install certificate <b>{certificateName}</b> on{" "}
{formattedHost}.
</span>
}
/>
{detail && (
<>
<RevealButton
isShowing={showDetails}
showText="Details"
hideText="Details"
caretPosition="after"
onClick={() => setShowDetails((prev) => !prev)}
/>
{showDetails && (
<Textarea label="Error details:" variant="code">
{detail}
</Textarea>
)}
</>
)}
</div>
<ModalFooter primaryButtons={<Button onClick={onCancel}>Done</Button>} />
</Modal>
);
};
export default CertificateInstallDetailsModal;
@@ -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;
}
}
@@ -0,0 +1,2 @@
export { default } from "./CertificateInstallDetailsModal";
export type { ICertificateInstallDetails } from "./CertificateInstallDetailsModal";
+6 -1
View File
@@ -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, string> = {
[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",
};
@@ -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<IVppInstallDetails | null>(null);
const [
certificateInstallDetails,
setCertificateInstallDetails,
] = useState<ICertificateInstallDetails | null>(null);
const [mdmCommandDetails, setMdmCommandDetails] = useState<ICommand | null>(
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 && (
<ActivityCard
className={
showAgentOptionsCard
? tripleHeightCardClass
: defaultCardClass
}
activeTab={activeActivityTab}
activities={
activeActivityTab === "past"
? pastActivities
: upcomingActivities
}
commands={
activeActivityTab === "past"
? pastMDMCommands
: upcomingMDMCommands
}
isLoading={
activeActivityTab === "past"
? pastActivitiesIsFetching || pastMDMCommandsIsFetching
: upcomingActivitiesIsFetching ||
upcomingMDMCommandsIsFetching
}
isError={
activeActivityTab === "past"
? pastActivitiesIsError || pastMDMCommandsIsError
: upcomingActivitiesIsError ||
upcomingMDMCommandsIsError
}
canCancelActivities={
isGlobalAdmin ||
isGlobalMaintainer ||
isHostTeamAdmin ||
isHostTeamMaintainer
}
showMDMCommandsToggle={canGetMDMCommands}
showMDMCommands={showMDMCommands}
onShowMDMCommands={() => {
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}
/>
)}
<ActivityCard
className={
showAgentOptionsCard
? tripleHeightCardClass
: defaultCardClass
}
activeTab={activeActivityTab}
activities={
activeActivityTab === "past"
? pastActivities
: upcomingActivities
}
commands={
activeActivityTab === "past"
? pastMDMCommands
: upcomingMDMCommands
}
isLoading={
activeActivityTab === "past"
? pastActivitiesIsFetching || pastMDMCommandsIsFetching
: upcomingActivitiesIsFetching ||
upcomingMDMCommandsIsFetching
}
isError={
activeActivityTab === "past"
? pastActivitiesIsError || pastMDMCommandsIsError
: upcomingActivitiesIsError || upcomingMDMCommandsIsError
}
canCancelActivities={
isGlobalAdmin ||
isGlobalMaintainer ||
isHostTeamAdmin ||
isHostTeamMaintainer
}
isUpcomingDisabled={isAndroidHost}
showMDMCommandsToggle={canGetMDMCommands}
showMDMCommands={showMDMCommands}
onShowMDMCommands={() => {
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}
/>
<UserCard
className={defaultCardClass}
endUsers={host.end_users ?? []}
@@ -1430,11 +1443,7 @@ const HostDetailsPage = ({
}}
/>
<LabelsCard
className={
!showActivityCard && !showAgentOptionsCard
? fullWidthCardClass
: defaultCardClass
}
className={defaultCardClass}
labels={host?.labels || []}
onLabelClick={onLabelClick}
/>
@@ -1651,6 +1660,12 @@ const HostDetailsPage = ({
onCancel={onCancelVppInstallDetailsModal}
/>
)}
{!!certificateInstallDetails && (
<CertificateInstallDetailsModal
details={certificateInstallDetails}
onCancel={() => setCertificateInstallDetails(null)}
/>
)}
{!!mdmCommandDetails && (
<CommandResultsModal
command={mdmCommandDetails}
@@ -58,6 +58,8 @@ interface IActivityProps {
* upcoming activities and mdm commands. */
upcomingCount: number;
canCancelActivities: boolean;
/** When true, the Upcoming tab is disabled with a tooltip */
isUpcomingDisabled?: boolean;
onChangeTab: (index: number, last: number, event: Event) => 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 = ({
<Tab>
<TabText>Past</TabText>
</Tab>
<Tab>
<TabText count={upcomingCount}>Upcoming</TabText>
<Tab disabled={isUpcomingDisabled}>
{isUpcomingDisabled ? (
<TooltipWrapper
tipContent="Currently, upcoming activity is only supported for macOS, Windows, Linux, iOS, and iPadOS hosts."
showArrow
underline={false}
>
<TabText>Upcoming</TabText>
</TooltipWrapper>
) : (
<TabText count={upcomingCount}>Upcoming</TabText>
)}
</Tab>
</TabList>
<TabPanel className={`${baseClass}__tab-panel`}>
@@ -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<
@@ -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 (
<ActivityItem
className={baseClass}
activity={activity}
hideCancel
hideShowDetails={!isFailed}
onShowDetails={onShowDetails}
>
{isFailed ? (
<>
<b>Fleet</b> failed to install certificate{" "}
<b>{activity.details?.certificate_name}</b> on this host.
</>
) : (
<>
<b>Fleet</b> installed certificate{" "}
<b>{activity.details?.certificate_name}</b> on this host.
</>
)}
</ActivityItem>
);
};
export default InstalledCertificateActivityItem;
@@ -0,0 +1 @@
export { default } from "./InstalledCertificateActivityItem";
@@ -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 (
<ActivityItem
className={baseClass}
activity={activity}
hideCancel
hideShowDetails
>
<b>{activity.actor_full_name}</b> resent{" "}
<b>{activity.details?.certificate_name}</b> certificate on this host.
</ActivityItem>
);
};
export default ResentCertificateActivityItem;
@@ -0,0 +1 @@
export { default } from "./ResentCertificateActivityItem";
+26
View File
@@ -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
}
+8
View File
@@ -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 {
+35 -1
View File
@@ -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
}
////////////////////////////////////////////////////////////////////////////////