diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index df0c6c37ffc..00f9f3b459a 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -108,6 +108,7 @@ export enum ActivityType { EnabledWindowsMdmMigration = "enabled_windows_mdm_migration", DisabledWindowsMdmMigration = "disabled_windows_mdm_migration", RanScript = "ran_script", + RanCustomMdmCommand = "ran_custom_mdm_command", RanScriptBatch = "ran_script_batch", ScheduledScriptBatch = "scheduled_script_batch", CanceledScriptBatch = "canceled_script_batch", @@ -215,7 +216,8 @@ export type IHostPastActivityType = | ActivityType.CreatedManagedLocalAccount | ActivityType.RotatedManagedLocalAccountPassword | ActivityType.FailedToRotateManagedLocalAccountPassword - | ActivityType.FailedEnrollmentProfileRenewal; + | ActivityType.FailedEnrollmentProfileRenewal + | ActivityType.RanCustomMdmCommand; /** This is a subset of ActivityType that are shown only for the host upcoming activities */ export type IHostUpcomingActivityType = @@ -297,6 +299,7 @@ export interface IActivityDetails { query_ids?: number[]; query_name?: string; query_sql?: string; + request_type?: string; role?: UserRole; script_execution_id?: string; script_name?: string; @@ -469,6 +472,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { locked_host: "Locked host", mdm_enrolled: "MDM turned on", mdm_unenrolled: "MDM turned off", + ran_custom_mdm_command: "Ran custom MDM command", ran_script: "Ran script", ran_script_batch: "Bulk ran script", scheduled_script_batch: "Scheduled script batch", diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index 555fd66563e..89cfcaa21de 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useRef, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; import { useQuery } from "react-query"; import { isEmpty } from "lodash"; import { InjectedRouter } from "react-router"; @@ -21,7 +22,11 @@ import { } from "interfaces/activity"; import { PerformanceImpactIndicator } from "interfaces/schedulable_query"; -import { getPerformanceImpactDescription } from "utilities/helpers"; +import { + formatMdmCommandNameForActivityItem, + getMdmCommandDisplayName, + getPerformanceImpactDescription, +} from "utilities/helpers"; import ShowQueryModal from "components/modals/ShowQueryModal"; import DataError from "components/DataError"; @@ -41,6 +46,11 @@ import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; import FailedEnrollmentProfileModal, { IFailedEnrollmentProfileModalProps, } from "components/modals/FailedEnrollmentProfileModal"; +import MdmCommandDetailsModal, { + GetIconName, + getVerbForCommandStatus, +} from "pages/hosts/components/CommandDetailsModal"; +import IconStatusMessage from "components/IconStatusMessage"; import GlobalActivityItem from "./GlobalActivityItem"; import ActivityAutomationDetailsModal from "./components/ActivityAutomationDetailsModal"; @@ -146,6 +156,11 @@ const ActivityFeed = ({ enrollmentProfileFailedDetails, setEnrollmentProfileFailedDetails, ] = useState | null>(null); + const [mdmCommandActivityDetails, setMdmCommandActivityDetails] = useState<{ + host_uuid?: string; + command_uuid: string; + actor_full_name?: string; + } | null>(null); const [searchQuery, setSearchQuery] = useState(""); const [createdAtDirection, setCreatedAtDirection] = useState("desc"); @@ -314,6 +329,17 @@ const ActivityFeed = ({ }, }); break; + case ActivityType.RanCustomMdmCommand: { + if (!details?.command_uuid) { + break; + } + setMdmCommandActivityDetails({ + command_uuid: details.command_uuid, + host_uuid: details?.host_uuid, + actor_full_name, + }); + break; + } default: break; } @@ -483,6 +509,58 @@ const ActivityFeed = ({ onDone={() => setEnrollmentProfileFailedDetails(null)} /> )} + {!!mdmCommandActivityDetails && ( + { + const isPending = GetIconName(result.status) === "pending-outline"; + const cmdDisplayName = getMdmCommandDisplayName( + result.request_type + ); + const timeAgo = result.updated_at + ? ` (${formatDistanceToNow(new Date(result.updated_at), { + addSuffix: true, + })})` + : ""; + return ( + + {cmdDisplayName ? ( + <> + {"The "} + {cmdDisplayName} + {" custom MDM command"} + + ) : ( + "A custom MDM command" + )} + {" is pending on "} + {result.hostname} + {`${timeAgo}.`} + + ) : ( + + {mdmCommandActivityDetails.actor_full_name && ( + {mdmCommandActivityDetails.actor_full_name} + )} + {` ${getVerbForCommandStatus(result.status)} `} + {formatMdmCommandNameForActivityItem(result.request_type)} + {" on "} + {result.hostname} + {"."} + + ) + } + /> + ); + }} + onDone={() => setMdmCommandActivityDetails(null)} + /> + )} ); }; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index 06c316705c1..39b1c4dbe4d 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx @@ -2181,4 +2181,29 @@ describe("Activity Feed", () => { screen.getByText(/installed all the software in self-service/i) ).toBeInTheDocument(); }); + + it("renders a ran_custom_mdm_command activity with a command name", () => { + const activity = createMockActivity({ + type: ActivityType.RanCustomMdmCommand, + details: { + request_type: "./Device/Vendor/MSFT/DMClient/Provider/DEMO/EntDMID", + host_display_name: "Huck's MacBook Pro", + }, + }); + render(); + + expect(screen.getByText(".../EntDMID")).toBeInTheDocument(); + expect(screen.getByText("Huck's MacBook Pro")).toBeInTheDocument(); + }); + + it("renders a ran_custom_mdm_command activity without a command name", () => { + const activity = createMockActivity({ + type: ActivityType.RanCustomMdmCommand, + details: { host_display_name: "Huck's MacBook Pro" }, + }); + render(); + + expect(screen.getByText(/a custom MDM command/i)).toBeInTheDocument(); + expect(screen.getByText("Huck's MacBook Pro")).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index 5bafa7ec4bb..bce6bb1010c 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -19,6 +19,7 @@ import { SCRIPT_PACKAGE_SOURCES, } from "interfaces/software"; import { + formatMdmCommandNameForActivityItem, formatScriptNameForActivityItem, getPerformanceImpactDescription, } from "utilities/helpers"; @@ -31,6 +32,7 @@ import { API_NO_TEAM_ID } from "interfaces/team"; const baseClass = "global-activity-item"; const ACTIVITIES_WITH_DETAILS = new Set([ + ActivityType.RanCustomMdmCommand, ActivityType.RanScript, ActivityType.AddedSoftware, ActivityType.EditedSoftware, @@ -1094,6 +1096,16 @@ const TAGGED_TEMPLATES = { ); }, + ranCustomMdmCommand: (activity: IActivity) => { + const { request_type, host_display_name } = activity.details || {}; + return ( + <> + {" "} + ran {formatMdmCommandNameForActivityItem(request_type)} on{" "} + {host_display_name}. + + ); + }, ranScript: (activity: IActivity) => { const { script_name, host_display_name, from_setup_experience } = activity.details || {}; @@ -2390,6 +2402,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.DisabledWindowsMdmMigration: { return TAGGED_TEMPLATES.disabledWindowsMdmMigration(); } + case ActivityType.RanCustomMdmCommand: { + return TAGGED_TEMPLATES.ranCustomMdmCommand(activity); + } case ActivityType.RanScript: { return TAGGED_TEMPLATES.ranScript(activity); } diff --git a/frontend/pages/hosts/components/CommandDetailsModal/CommandDetailsModal.tests.tsx b/frontend/pages/hosts/components/CommandDetailsModal/CommandDetailsModal.tests.tsx new file mode 100644 index 00000000000..5310a7f7394 --- /dev/null +++ b/frontend/pages/hosts/components/CommandDetailsModal/CommandDetailsModal.tests.tsx @@ -0,0 +1,69 @@ +import { GetIconName, getVerbForCommandStatus } from "./CommandDetailsModal"; + +describe("GetIconName", () => { + it("returns error for Apple Error status", () => { + expect(GetIconName("Error")).toEqual("error"); + }); + + it("returns error for Apple CommandFormatError status", () => { + expect(GetIconName("CommandFormatError")).toEqual("error"); + }); + + it("returns success for Apple Acknowledged status", () => { + expect(GetIconName("Acknowledged")).toEqual("success"); + }); + + it("returns pending-outline for Apple Pending status", () => { + expect(GetIconName("Pending")).toEqual("pending-outline"); + }); + + it("returns pending-outline for Apple NotNow status", () => { + expect(GetIconName("NotNow")).toEqual("pending-outline"); + }); + + it("returns success for Windows 200 status", () => { + expect(GetIconName("200")).toEqual("success"); + }); + + it("returns error for Windows 400 status", () => { + expect(GetIconName("400")).toEqual("error"); + }); + + it("returns error for Windows 500 status", () => { + expect(GetIconName("500")).toEqual("error"); + }); + + it("returns pending-outline for Windows 101 status", () => { + expect(GetIconName("101")).toEqual("pending-outline"); + }); + + it("returns pending-outline for Windows 199 status (upper pending boundary)", () => { + expect(GetIconName("199")).toEqual("pending-outline"); + }); + + it("returns success for Windows 399 status (upper success boundary)", () => { + expect(GetIconName("399")).toEqual("success"); + }); + + it("returns warning for an unknown status", () => { + expect(GetIconName("unknown")).toEqual("warning"); + }); +}); + +describe("getVerbForCommandStatus", () => { + it("returns 'ran' for a successful status", () => { + expect(getVerbForCommandStatus("Acknowledged")).toEqual("ran"); + }); + + it("returns 'failed to run' for an error status", () => { + expect(getVerbForCommandStatus("Error")).toEqual("failed to run"); + }); + + it("returns 'sent' for a pending status", () => { + expect(getVerbForCommandStatus("Pending")).toEqual("sent"); + }); + + it("returns 'sent' for an unknown status", () => { + expect(getVerbForCommandStatus("unknown")).toEqual("sent"); + }); +}); diff --git a/frontend/pages/hosts/components/CommandDetailsModal/CommandDetailsModal.tsx b/frontend/pages/hosts/components/CommandDetailsModal/CommandDetailsModal.tsx index f3f536283f8..f9bf538333c 100644 --- a/frontend/pages/hosts/components/CommandDetailsModal/CommandDetailsModal.tsx +++ b/frontend/pages/hosts/components/CommandDetailsModal/CommandDetailsModal.tsx @@ -23,22 +23,41 @@ import Button from "components/buttons/Button"; const baseClass = "command-details-modal"; export const GetIconName = (status: string): IconNames => { + // Apple MDM status strings switch (status) { case "Error": - return "error"; case "CommandFormatError": return "error"; case "Acknowledged": return "success"; case "Pending": - return "pending-outline"; case "NotNow": return "pending-outline"; + default: + break; + } + // Windows OMA-DM status codes (numeric strings): 101 = pending, 200-399 = ran, 400+ = failed + const code = parseInt(status, 10); + if (!Number.isNaN(code)) { + if (code >= 400) return "error"; + if (code >= 200) return "success"; + return "pending-outline"; + } + return "warning"; +}; +export const getVerbForCommandStatus = (status: string): string => { + const icon = GetIconName(status); + switch (icon) { + case "error": + return "failed to run"; + case "success": + return "ran"; + case "pending-outline": + return "sent"; default: - // FIXME: update for other platforms and design appropriate default handling for unknown - // statuses; for now, just return warning icon to indicate unknown state - return "warning"; + // unknown status + return "sent"; } }; diff --git a/frontend/pages/hosts/components/CommandDetailsModal/index.ts b/frontend/pages/hosts/components/CommandDetailsModal/index.ts index 31e35d5a58f..27db53e049e 100644 --- a/frontend/pages/hosts/components/CommandDetailsModal/index.ts +++ b/frontend/pages/hosts/components/CommandDetailsModal/index.ts @@ -1,2 +1,2 @@ export { default } from "./CommandDetailsModal"; -export { GetIconName } from "./CommandDetailsModal"; +export { GetIconName, getVerbForCommandStatus } from "./CommandDetailsModal"; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index bcf7c0b0f29..6581f3148c0 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1,4 +1,5 @@ import React, { useContext, useState, useCallback, useEffect } from "react"; +import { formatDistanceToNow } from "date-fns"; import { Params, InjectedRouter } from "react-router/lib/Router"; import { useQuery } from "react-query"; import { useErrorHandler } from "react-error-boundary"; @@ -40,7 +41,12 @@ import { import { FLEET_FILEVAULT_PROFILE_DISPLAY_NAME } from "interfaces/mdm"; import { ICommand } from "interfaces/command"; -import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers"; +import { + formatMdmCommandNameForActivityItem, + getMdmCommandDisplayName, + normalizeEmptyValues, + wrapFleetHelper, +} from "utilities/helpers"; import permissions from "utilities/permissions"; import { DOCUMENT_TITLE_SUFFIX, @@ -91,7 +97,11 @@ import CertificateInstallDetailsModal, { } from "components/ActivityDetails/InstallDetails/CertificateInstallDetailsModal"; import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; -import CommandResultsModal from "pages/hosts/components/CommandDetailsModal"; +import CommandResultsModal, { + GetIconName, + getVerbForCommandStatus, +} from "pages/hosts/components/CommandDetailsModal"; +import IconStatusMessage from "components/IconStatusMessage"; import FailedEnrollmentProfileModal, { IFailedEnrollmentProfileModalProps, } from "components/modals/FailedEnrollmentProfileModal"; @@ -284,6 +294,11 @@ const HostDetailsPage = ({ const [mdmCommandDetails, setMdmCommandDetails] = useState( null ); + const [activityCommandDetails, setActivityCommandDetails] = useState<{ + host_uuid: string; + command_uuid: string; + actor_full_name?: string; + } | null>(null); const [ enrollmentProfileFailedDetails, setEnrollmentProfileFailedDetails, @@ -892,6 +907,18 @@ const HostDetailsPage = ({ }, }); break; + case ActivityType.RanCustomMdmCommand: { + const resolvedHostUuid = details?.host_uuid ?? host?.uuid; + if (!details?.command_uuid || !resolvedHostUuid) { + break; + } + setActivityCommandDetails({ + command_uuid: details.command_uuid, + host_uuid: resolvedHostUuid, + actor_full_name, + }); + break; + } default: // do nothing } }, @@ -1833,9 +1860,82 @@ const HostDetailsPage = ({ {!!mdmCommandDetails && ( ( + + {formatMdmCommandNameForActivityItem( + result.request_type + )} + {" on "} + {result.hostname} + {"."} + + } + /> + ) + : undefined + } onDone={onCancelMdmCommandDetailsModal} /> )} + {!!activityCommandDetails && ( + { + const isPending = + GetIconName(result.status) === "pending-outline"; + const cmdDisplayName = getMdmCommandDisplayName( + result.request_type + ); + const timeAgo = result.updated_at + ? ` (${formatDistanceToNow(new Date(result.updated_at), { + addSuffix: true, + })})` + : ""; + return ( + + {cmdDisplayName ? ( + <> + {"The "} + {cmdDisplayName} + {" custom MDM command"} + + ) : ( + "A custom MDM command" + )} + {" is pending on "} + {result.hostname} + {`${timeAgo}.`} + + ) : ( + + {activityCommandDetails.actor_full_name && ( + {activityCommandDetails.actor_full_name} + )} + {` ${getVerbForCommandStatus(result.status)} `} + {formatMdmCommandNameForActivityItem( + result.request_type + )} + {" on this host."} + + ) + } + /> + ); + }} + onDone={() => setActivityCommandDetails(null)} + /> + )} {enrollmentProfileFailedDetails && ( { + return ( + + {activity.actor_full_name} + {" ran "} + {formatMdmCommandNameForActivityItem(activity.details?.request_type)} + {" on this host."} + + ); +}; + +export default RanCustomMdmCommandActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanCustomMdmCommandActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanCustomMdmCommandActivityItem/index.ts new file mode 100644 index 00000000000..bbcb4c30868 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanCustomMdmCommandActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./RanCustomMdmCommandActivityItem"; diff --git a/frontend/utilities/helpers.tests.tsx b/frontend/utilities/helpers.tests.tsx index 9ce2fc93956..a8f4e8b7ab1 100644 --- a/frontend/utilities/helpers.tests.tsx +++ b/frontend/utilities/helpers.tests.tsx @@ -4,6 +4,7 @@ import helpers, { removeOSPrefix, compareVersions, willExpireWithinXDays, + getMdmCommandDisplayName, } from "./helpers"; describe("helpers utilities", () => { @@ -98,4 +99,32 @@ describe("helpers utilities", () => { expect(result.org_info).toEqual({ org_name: "Fleet" }); }); }); + + describe("getMdmCommandDisplayName function", () => { + it("returns empty string for undefined", () => { + expect(getMdmCommandDisplayName(undefined)).toEqual(""); + }); + + it("returns empty string for empty string", () => { + expect(getMdmCommandDisplayName("")).toEqual(""); + }); + + it("returns the value as-is for a simple command name with no path separator", () => { + expect(getMdmCommandDisplayName("DeviceInformation")).toEqual( + "DeviceInformation" + ); + }); + + it("truncates a multi-segment Windows OMA-URI path to the last segment", () => { + expect( + getMdmCommandDisplayName( + "./Device/Vendor/MSFT/DMClient/Provider/DEMO/EntDMID" + ) + ).toEqual(".../EntDMID"); + }); + + it("handles a trailing slash by ignoring the empty final segment", () => { + expect(getMdmCommandDisplayName("./Vendor/MSFT/")).toEqual(".../MSFT"); + }); + }); }); diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index 4fe433d1a4d..c80e9ab1316 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -450,6 +450,30 @@ export const formatScriptNameForActivityItem = (name: string | undefined) => { ); }; +export const getMdmCommandDisplayName = ( + requestType: string | undefined +): string => { + if (!requestType) return ""; + const segments = requestType.split("/").filter(Boolean); + if (segments.length === 0) return requestType; + const lastSegment = segments[segments.length - 1]; + return segments.length > 1 ? `.../${lastSegment}` : lastSegment; +}; + +export const formatMdmCommandNameForActivityItem = ( + requestType: string | undefined +) => { + const displayName = getMdmCommandDisplayName(requestType); + if (!displayName) { + return <>a custom MDM command; + } + return ( + <> + {displayName} as a custom MDM command + + ); +}; + export const generateRole = ( teams: ITeam[], globalRole: UserRole | null