diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index a59e9c3773..d61b327676 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -73,13 +73,15 @@ export enum ActivityType { ResentConfigurationProfile = "resent_configuration_profile", AddedSoftware = "added_software", DeletedSoftware = "deleted_software", + InstalledSoftware = "installed_software", } // This is a subset of ActivityType that are shown only for the host past activities -export type IHostPastActivityType = +export type IHostActivityType = | ActivityType.RanScript | ActivityType.LockedHost - | ActivityType.UnlockedHost; + | ActivityType.UnlockedHost + | ActivityType.InstalledSoftware; export interface IActivity { created_at: string; @@ -92,8 +94,9 @@ export interface IActivity { details?: IActivityDetails; } -export type IPastActivity = Omit & { - type: IHostPastActivityType; +export type IHostActivity = Omit & { + type: IHostActivityType; + details: IActivityDetails; }; export interface IActivityDetails { @@ -120,6 +123,7 @@ export interface IActivityDetails { host_display_name?: string; host_display_names?: string[]; host_ids?: number[]; + host_id?: number; host_platform?: string; installed_from_dep?: boolean; mdm_platform?: "microsoft" | "apple"; @@ -134,7 +138,8 @@ export interface IActivityDetails { deadline_days?: number; grace_period_days?: number; stats?: ISchedulableQueryStats; - host_id?: number; software_title?: string; software_package?: string; + status?: string; + install_uuid?: string; } diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 8023b25088..064d583ccc 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -148,3 +148,32 @@ export const formatSoftwareType = ({ } return type; }; +/* + * SoftwareInstallStatus represents the possible states of software install operations. + * + */ +export type SoftwareInstallStatus = "pending" | "installed" | "failed"; + +/** + * + * ISoftwareInstallResult is the shape of a software install result object + * returned by the Fleet API. + * + */ +export interface ISoftwareInstallResult { + install_uuid: string; + software_title: string; + software_title_id: number; + software_package: string; + host_id: number; + host_display_name: string; + status: SoftwareInstallStatus; + detail: string; + output: string; + pre_install_query_output: string; + post_install_script_output: string; +} + +export interface ISoftwareInstallResults { + results: ISoftwareInstallResult; +} diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index ed693a2a24..e4a6f90c7b 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -15,6 +15,9 @@ import Button from "components/buttons/Button"; import Spinner from "components/Spinner"; // @ts-ignore import FleetIcon from "components/icons/FleetIcon"; + +import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails"; + import ActivityItem from "./ActivityItem"; import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal"; @@ -35,6 +38,7 @@ const ActivityFeed = ({ const [pageIndex, setPageIndex] = useState(0); const [showShowQueryModal, setShowShowQueryModal] = useState(false); const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false); + const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState(""); const queryShown = useRef(""); const queryImpact = useRef(undefined); const scriptExecutionId = useRef(""); @@ -81,6 +85,7 @@ const ActivityFeed = ({ activityType: ActivityType, details: IActivityDetails ) => { + console.log("activityType", activityType); switch (activityType) { case ActivityType.LiveQuery: queryShown.current = details.query_sql ?? ""; @@ -93,6 +98,11 @@ const ActivityFeed = ({ scriptExecutionId.current = details.script_execution_id ?? ""; setShowScriptDetailsModal(true); break; + case ActivityType.InstalledSoftware: + // installUuid.current = details.install_uuid ?? ""; + // console.log("installUuid.current", installUuid.current); + setInstalledSoftwareUuid(details.install_uuid ?? ""); + break; default: break; } @@ -184,6 +194,12 @@ const ActivityFeed = ({ onCancel={() => setShowScriptDetailsModal(false)} /> )} + {installedSoftwareUuid && ( + setInstalledSoftwareUuid("")} + /> + )} ); }; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 3407f2363e..7378d190ee 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -16,6 +16,7 @@ import Icon from "components/Icon"; import ReactTooltip from "react-tooltip"; import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; import { COLORS } from "styles/var/colors"; +import { getSoftwareInstallStatusPredicate } from "pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem"; const baseClass = "activity-item"; @@ -854,6 +855,42 @@ const TAGGED_TEMPLATES = { ); }, + installedSoftware: ( + activity: IActivity, + onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void + ) => { + const { details } = activity; + if (!details) { + return TAGGED_TEMPLATES.defaultActivityTemplate(activity); + } + + console.log("onDetailsClick", onDetailsClick); + + const { + host_display_name: hostName, + software_title: title, + status, + install_uuid, + } = details; + + return ( + <> + {" "} + {getSoftwareInstallStatusPredicate(status)} {title} software on{" "} + {hostName}.{" "} + + + ); + }, }; const getDetail = ( @@ -1033,6 +1070,9 @@ const getDetail = ( case ActivityType.DeletedSoftware: { return TAGGED_TEMPLATES.deletedSoftware(activity); } + case ActivityType.InstalledSoftware: { + return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick); + } default: { return TAGGED_TEMPLATES.defaultActivityTemplate(activity); diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index f35332c84a..bf18d8c47d 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -41,9 +41,9 @@ const SoftwareDetailsSummary = ({

{title}

- {type && } + {!!type && } - {versions && } + {!!versions && }
diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx b/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx new file mode 100644 index 0000000000..4c9c5fa77b --- /dev/null +++ b/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { useQuery } from "react-query"; + +import { + ISoftwareInstallResult, + ISoftwareInstallResults, + SoftwareInstallStatus, +} from "interfaces/software"; +import softwareAPI from "services/entities/software"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import Textarea from "components/Textarea"; +import DataError from "components/DataError/DataError"; +import Spinner from "components/Spinner/Spinner"; +import { IconNames } from "components/icons"; + +const baseClass = "software-install-details"; + +const STATUS_ICONS: Record = { + pending: "pending-outline", + installed: "success-outline", + failed: "error-outline", +} as const; + +const STATUS_PREDICATES: Record = { + pending: "will install", + installed: "installed", + failed: "failed to install", +} as const; + +const StatusMessage = ({ + result: { host_display_name, software_package, software_title, status }, +}: { + result: ISoftwareInstallResult; +}) => { + return ( +
+ + + Fleet {STATUS_PREDICATES[status]} {software_title} ( + {software_package}) on {host_display_name} + {status === "pending" ? " when it comes online" : ""}. + +
+ ); +}; + +const OUTPUT_DISPLAY_LABELS = { + pre_install_query_output: "Pre-install condition", + output: "Software install output", + post_install_script_output: "Post-install script output", +} as const; + +const Output = ({ + displayKey, + result, +}: { + displayKey: keyof typeof OUTPUT_DISPLAY_LABELS; + result: ISoftwareInstallResult; +}) => { + return ( +
+ {OUTPUT_DISPLAY_LABELS[displayKey]}: + +
+ ); +}; + +export const SoftwareInstallDetails = ({ + installUuid, +}: { + installUuid: string; +}) => { + const { data: result, isLoading, isError } = useQuery< + ISoftwareInstallResults, + Error, + ISoftwareInstallResult + >( + ["softwareInstallResults", installUuid], + () => { + return softwareAPI.getSoftwareInstallResult(installUuid); + }, + { + refetchOnWindowFocus: false, + select: (data) => data.results, + } + ); + + if (isLoading) { + return ; + } else if (isError) { + return ; + } else if (!result) { + // FIXME: Find a better solution for this. + return ; + } + + return ( + <> +
+ + {result.status !== "pending" && ( + <> + {result.pre_install_query_output && ( + + )} + {result.output && } + {result.post_install_script_output && ( + + )} + + )} +
+ + ); +}; + +export const SoftwareInstallDetailsModal = ({ + installUuid, + onCancel, +}: { + installUuid: string; + onCancel: () => void; +}) => { + return ( + + <> +
+ +
+
+ +
+ +
+ ); +}; diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/_styles.scss b/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/_styles.scss new file mode 100644 index 0000000000..390ae3a59e --- /dev/null +++ b/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/_styles.scss @@ -0,0 +1,17 @@ +.software-install-details { + .modal__content { + margin-top: $pad-xlarge; + } + &__status-message { + display: flex; + align-items: center; + gap: $pad-small; + margin: 0; + } + &__script-output { + padding-top: $pad-xlarge; + .textarea { + margin-top: $pad-medium; + } + } +} diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/index.ts b/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/index.ts new file mode 100644 index 0000000000..aaaabc0082 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/index.ts @@ -0,0 +1,4 @@ +export { + SoftwareInstallDetails, + SoftwareInstallDetailsModal, +} from "./SoftwareInstallDetails"; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 5370d3f6b9..ebfa15770b 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -13,7 +13,7 @@ import { QueryContext } from "context/query"; import { NotificationContext } from "context/notification"; import activitiesAPI, { - IPastActivitiesResponse, + IHostActivitiesResponse, IUpcomingActivitiesResponse, } from "services/entities/activities"; import hostAPI from "services/entities/hosts"; @@ -58,6 +58,7 @@ import TabsWrapper from "components/TabsWrapper"; import MainContent from "components/MainContent"; import BackLink from "components/BackLink"; import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal"; +import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails"; import HostSummaryCard from "../cards/HostSummary"; import AboutCard from "../cards/About"; @@ -171,6 +172,7 @@ const HostDetailsPage = ({ const [selectedPolicy, setSelectedPolicy] = useState( null ); + const [softwareInstallUuid, setSoftwareInstallUuid] = useState(""); const [isUpdatingHost, setIsUpdatingHost] = useState(false); const [refetchStartTime, setRefetchStartTime] = useState(null); const [showRefetchSpinner, setShowRefetchSpinner] = useState(false); @@ -369,9 +371,9 @@ const HostDetailsPage = ({ isError: pastActivitiesIsError, refetch: refetchPastActivities, } = useQuery< - IPastActivitiesResponse, + IHostActivitiesResponse, Error, - IPastActivitiesResponse, + IHostActivitiesResponse, Array<{ scope: string; pageIndex: number; @@ -552,6 +554,9 @@ const HostDetailsPage = ({ case "ran_script": setScriptDetailsId(details?.script_execution_id || ""); break; + case "installed_software": + setSoftwareInstallUuid(details?.install_uuid || ""); + break; default: // do nothing } }, @@ -582,7 +587,11 @@ const HostDetailsPage = ({ const onCancelScriptDetailsModal = useCallback(() => { setScriptDetailsId(""); - }, [setScriptDetailsId]); + }, []); + + const onCancelSoftwareInstallDetailsModal = useCallback(() => { + setSoftwareInstallUuid(""); + }, []); const onTransferHostSubmit = async (team: ITeam) => { setIsUpdatingHost(true); @@ -967,6 +976,12 @@ const HostDetailsPage = ({ onCancel={onCancelScriptDetailsModal} /> )} + {!!softwareInstallUuid && ( + + )} {showLockHostModal && ( { interface IActivityProps { activeTab: "past" | "upcoming"; - activities?: IPastActivitiesResponse | IUpcomingActivitiesResponse; + activities?: IHostActivitiesResponse | IUpcomingActivitiesResponse; isLoading?: boolean; isError?: boolean; upcomingCount: number; @@ -101,7 +101,7 @@ const Activity = ({ | React.FC > = { [ActivityType.RanScript]: RanScriptActivityItem, [ActivityType.LockedHost]: LockedHostActivityItem, [ActivityType.UnlockedHost]: UnlockedHostActivityItem, + [ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem, }; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx new file mode 100644 index 0000000000..fd0d7d3fa6 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +import { SoftwareInstallStatus } from "interfaces/software"; + +import { IHostActivityItemComponentPropsWithShowDetails } from "../../ActivityConfig"; +import HostActivityItem from "../../HostActivityItem"; +import ShowDetailsButton from "../../ShowDetailsButton"; + +const baseClass = "installed-software-activity-item"; + +const STATUS_PREDICATES: Record = { + failed: "failed to install", + installed: "installed", + pending: "told Fleet to install", +} as const; + +export const getSoftwareInstallStatusPredicate = ( + status: string | undefined +) => { + if (!status) { + return STATUS_PREDICATES.pending; + } + return ( + STATUS_PREDICATES[status as SoftwareInstallStatus] || + STATUS_PREDICATES.pending + ); +}; + +const InstalledSoftwareActivityItem = ({ + activity, + onShowDetails, +}: IHostActivityItemComponentPropsWithShowDetails) => { + const { actor_full_name: actorName, details } = activity; + + const { status, software_title: title } = details; + + return ( + + {actorName} {getSoftwareInstallStatusPredicate(status)}{" "} + {title} software on this host.{" "} + + + ); +}; + +export default InstalledSoftwareActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/index.ts new file mode 100644 index 0000000000..868ba94b63 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./InstalledSoftwareActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx index 05c7a790a8..97f0346141 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { IPastActivity } from "interfaces/activity"; -import { IPastActivitiesResponse } from "services/entities/activities"; +import { IHostActivity } from "interfaces/activity"; +import { IHostActivitiesResponse } from "services/entities/activities"; // @ts-ignore import FleetIcon from "components/icons/FleetIcon"; @@ -16,7 +16,7 @@ import { pastActivityComponentMap } from "../ActivityConfig"; const baseClass = "past-activity-feed"; interface IPastActivityFeedProps { - activities?: IPastActivitiesResponse; + activities?: IHostActivitiesResponse; isError?: boolean; onDetailsClick: ShowActivityDetailsHandler; onNextPage: () => void; @@ -53,7 +53,7 @@ const PastActivityFeed = ({ return (
- {activitiesList.map((activity: IPastActivity) => { + {activitiesList.map((activity: IHostActivity) => { const ActivityItemComponent = pastActivityComponentMap[activity.type]; return ( { + switch (type) { + case ActivityType.RanScript: + return ( + <> + told Fleet to run{" "} + {formatScriptNameForActivityItem(details?.script_name)} + + ); + case ActivityType.InstalledSoftware: + return ( + <> + told Fleet to install{" "} + {details?.software_title ? ( + <> + {details.software_title}{" "} + + ) : ( + "" + )} + software + + ); + default: + // this should never happen + return <>{type}; + } +}; + // TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and // frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx const UpcomingActivity = ({ @@ -46,23 +75,16 @@ const UpcomingActivity = ({
- {activity.actor_full_name} - <> - {" "} - told Fleet to run{" "} - {formatScriptNameForActivityItem( - activity.details?.script_name - )}{" "} - on this host.{" "} - - + {activity.actor_full_name} {formatPredicate(activity)} on + this host.{" "} +
void; @@ -52,8 +52,9 @@ const UpcomingActivityFeed = ({ return (
- {activitiesList.map((activity: IActivity) => ( + {activitiesList.map((activity: IHostActivity) => ( diff --git a/frontend/services/entities/activities.ts b/frontend/services/entities/activities.ts index 7a45e52b83..61123bddc4 100644 --- a/frontend/services/entities/activities.ts +++ b/frontend/services/entities/activities.ts @@ -1,5 +1,5 @@ import endpoints from "utilities/endpoints"; -import { IActivity, IPastActivity } from "interfaces/activity"; +import { IActivity, IHostActivity } from "interfaces/activity"; import sendRequest from "services"; import { buildQueryStringFromParams } from "utilities/url"; @@ -16,15 +16,15 @@ export interface IActivitiesResponse { }; } -export interface IPastActivitiesResponse { - activities: IPastActivity[] | null; +export interface IHostActivitiesResponse { + activities: IHostActivity[] | null; meta: { has_next_results: boolean; has_previous_results: boolean; }; } -export interface IUpcomingActivitiesResponse extends IActivitiesResponse { +export interface IUpcomingActivitiesResponse extends IHostActivitiesResponse { count: number; } @@ -53,7 +53,7 @@ export default { id: number, page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE - ): Promise => { + ): Promise => { const { HOST_PAST_ACTIVITIES } = endpoints; const queryParams = { diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index d9ebf6bc4c..f27bb56502 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -231,4 +231,10 @@ export default { })}`; return sendRequest("GET", path); }, + + getSoftwareInstallResult: (installUuid: string) => { + const { SOFTWARE_INSTALL_RESULTS } = endpoints; + const path = SOFTWARE_INSTALL_RESULTS(installUuid); + return sendRequest("GET", path); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 2245a69d15..1ecc1c726a 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -131,6 +131,8 @@ export default { SOFTWARE_PACKAGE_ADD: `/${API_VERSION}/fleet/software/package`, SOFTWARE_PACKAGE: (id: number) => `/${API_VERSION}/fleet/software/packages/${id}`, + SOFTWARE_INSTALL_RESULTS: (uuid: string) => + `/${API_VERSION}/fleet/software/install/results/${uuid}`, // AI endpoints AUTOFILL_POLICY: `/${API_VERSION}/fleet/autofill/policy`, diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index 906f249ab0..5ac8d2355c 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -55,6 +55,7 @@ import { } from "utilities/constants"; import { ISchedulableQueryStats } from "interfaces/schedulable_query"; import { IDropdownOption } from "interfaces/dropdownOption"; +import { IActivityDetails } from "interfaces/activity"; const ORG_INFO_ATTRS = ["org_name", "org_logo_url"]; const ADMIN_ATTRS = ["email", "name", "password", "password_confirmation"];