From 25d08d1051c8cfb83d91a2600e00a93db885c09c Mon Sep 17 00:00:00 2001 From: Jacob Shandling Date: Tue, 3 Sep 2024 15:45:55 -0700 Subject: [PATCH 01/29] change file --- changes/20320-uninstall-packages | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/20320-uninstall-packages diff --git a/changes/20320-uninstall-packages b/changes/20320-uninstall-packages new file mode 100644 index 0000000000..89ab892841 --- /dev/null +++ b/changes/20320-uninstall-packages @@ -0,0 +1 @@ +* Implement the ability to use Fleet to uninstall packages from hosts. \ No newline at end of file From 0cfbdc6f5898593364b92399ed25cc44596c43cf Mon Sep 17 00:00:00 2001 From: jacobshandling <61553566+jacobshandling@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:11:14 -0700 Subject: [PATCH 02/29] =?UTF-8?q?UI=20=E2=80=93=C2=A0Implement=20changes?= =?UTF-8?q?=20for=20package=20uninstall=20scripts=20in=20the=20add=20softw?= =?UTF-8?q?are=20modal=20(#21828)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Addresses #21564 – see issue for task list ![Screenshot 2024-09-04 at 5 45 12 PM](https://github.com/user-attachments/assets/546401dd-b56e-4c39-baba-456dc844ee0f) ![Screenshot 2024-09-04 at 5 42 57 PM](https://github.com/user-attachments/assets/810ca450-0ddd-4258-96a5-bddb300ae19d) ![Screenshot 2024-09-04 at 5 45 02 PM](https://github.com/user-attachments/assets/32a19ce6-52c3-4772-ba53-00e50145bc85) ![Screenshot 2024-09-04 at 5 43 23 PM](https://github.com/user-attachments/assets/925843fb-6290-489b-a639-de1cbfba83fa) - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- .../buttons/RevealButton/RevealButton.tsx | 31 ++- frontend/interfaces/package_type.ts | 22 ++ .../AddPackageAdvancedOptions.tsx | 249 ++++++++++++++---- .../AddPackageForm/AddPackageForm.tsx | 39 ++- .../components/AddPackageForm/helpers.ts | 4 +- frontend/styles/var/mixins.scss | 3 + .../utilities/software_install_scripts.ts | 4 +- .../utilities/software_uninstall_scripts.ts | 30 +++ pkg/file/scripts/install_exe.ps1 | 28 +- pkg/file/scripts/uninstall_deb.sh | 4 + pkg/file/scripts/uninstall_exe.ps1 | 17 ++ pkg/file/scripts/uninstall_msi.ps1 | 4 + pkg/file/scripts/uninstall_pkg.sh | 17 ++ 13 files changed, 364 insertions(+), 88 deletions(-) create mode 100644 frontend/interfaces/package_type.ts create mode 100644 frontend/utilities/software_uninstall_scripts.ts create mode 100644 pkg/file/scripts/uninstall_deb.sh create mode 100644 pkg/file/scripts/uninstall_exe.ps1 create mode 100644 pkg/file/scripts/uninstall_msi.ps1 create mode 100644 pkg/file/scripts/uninstall_pkg.sh diff --git a/frontend/components/buttons/RevealButton/RevealButton.tsx b/frontend/components/buttons/RevealButton/RevealButton.tsx index c1db68f9d9..c54d5a0010 100644 --- a/frontend/components/buttons/RevealButton/RevealButton.tsx +++ b/frontend/components/buttons/RevealButton/RevealButton.tsx @@ -13,6 +13,7 @@ export interface IRevealButtonProps { autofocus?: boolean; disabled?: boolean; tooltipContent?: React.ReactNode; + disabledTooltipContent?: React.ReactNode; onClick?: | ((value?: any) => void) | ((evt: React.MouseEvent) => void); @@ -29,6 +30,7 @@ const RevealButton = ({ autofocus, disabled, tooltipContent, + disabledTooltipContent, onClick, }: IRevealButtonProps): JSX.Element => { const classNames = classnames(baseClass, className); @@ -36,11 +38,12 @@ const RevealButton = ({ const buttonContent = () => { const text = isShowing ? hideText : showText; - const buttonText = tooltipContent ? ( - {text} - ) : ( - text - ); + const buttonText = + tooltipContent && !disabled ? ( + {text} + ) : ( + text + ); return ( <> @@ -61,7 +64,7 @@ const RevealButton = ({ ); }; - return ( + const button = ( + + + + ); +}; + +export default SoftwareUninstallDetailsModal; diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss new file mode 100644 index 0000000000..ca0ad4a2c1 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss @@ -0,0 +1,23 @@ +.software-uninstall-details-modal { + &__modal-content { + display: flex; + flex-direction: column; + gap: 2rem; + } + &__status-message { + display: flex; + align-items: center; + gap: $pad-small; + margin: 0; + .icon { + align-self: flex-start; + } + } + &__script-output { + .textarea { + margin-top: $pad-medium; + overflow-wrap: break-word; + font-family: "SourceCodePro", $monospace; + } + } +} diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts new file mode 100644 index 0000000000..c57d50fe8d --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareUninstallDetailsModal"; diff --git a/frontend/components/ActivityDetails/InstallDetails/constants.ts b/frontend/components/ActivityDetails/InstallDetails/constants.ts index e717257390..255b12e5fd 100644 --- a/frontend/components/ActivityDetails/InstallDetails/constants.ts +++ b/frontend/components/ActivityDetails/InstallDetails/constants.ts @@ -5,10 +5,9 @@ export const INSTALL_DETAILS_STATUS_ICONS: Record< SoftwareInstallStatus, IconNames > = { - pending: "pending-outline", pending_install: "pending-outline", installed: "success-outline", - failed: "error-outline", + uninstalled: "success-outline", failed_install: "error-outline", pending_uninstall: "pending-outline", failed_uninstall: "error-outline", @@ -18,10 +17,9 @@ const INSTALL_DETAILS_STATUS_PREDICATES: Record< SoftwareInstallStatus, string > = { - pending: "is installing or will install", pending_install: "is installing or will install", installed: "installed", - failed: "failed to install", + uninstalled: "uninstalled", failed_install: "failed to install", pending_uninstall: "is uninstalling or will uninstall", failed_uninstall: "failed to uninstall", diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index a071a1237b..2818accc7c 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -66,8 +66,10 @@ export interface ISoftwarePackage { icon_url: string | null; status: { installed: number; - pending: number; - failed: number; + pending_install: number; + failed_install: number; + pending_uninstall: number; + failed_uninstall: number; }; } @@ -194,42 +196,59 @@ export const formatSoftwareType = ({ /** * This list comprises all possible states of software install operations. */ -export const SOFTWARE_INSTALL_STATUSES = [ - "failed", - "failed_install", - "installed", - "pending", - "pending_install", +export const SOFTWARE_UNINSTALL_STATUSES = [ + "uninstalled", "pending_uninstall", "failed_uninstall", ] as const; +export type SoftwareUninstallStatus = typeof SOFTWARE_UNINSTALL_STATUSES[number]; + +export const SOFTWARE_INSTALL_STATUSES = [ + "installed", + "pending_install", + "failed_install", + ...SOFTWARE_UNINSTALL_STATUSES, +] as const; + /* * SoftwareInstallStatus represents the possible states of software install operations. */ export type SoftwareInstallStatus = typeof SOFTWARE_INSTALL_STATUSES[number]; export const isValidSoftwareInstallStatus = ( - s: string | undefined + s: string | undefined | null ): s is SoftwareInstallStatus => !!s && SOFTWARE_INSTALL_STATUSES.includes(s as SoftwareInstallStatus); +export const isSoftwareUninstallStatus = ( + s: string | undefined | null +): s is SoftwareUninstallStatus => + !!s && SOFTWARE_UNINSTALL_STATUSES.includes(s as SoftwareUninstallStatus); + +// not a typeguard, as above 2 functions are +export const isPendingStatus = (s: string | undefined | null) => + ["pending_install", "pending_uninstall"].includes(s || ""); + /** * ISoftwareInstallResult is the shape of a software install result object * returned by the Fleet API. */ export interface ISoftwareInstallResult { + host_display_name?: string; 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; + created_at: string; + updated_at: string | null; + self_service: boolean; } export interface ISoftwareInstallResults { @@ -280,18 +299,21 @@ export interface IHostSoftware { app_store_app: IHostAppStoreApp | null; source: string; bundle_identifier?: string; - status: SoftwareInstallStatus | null; + status: Exclude | null; installed_versions: ISoftwareInstallVersion[] | null; } export type IDeviceSoftware = IHostSoftware; -const INSTALL_STATUS_PREDICATES: Record = { - failed: "failed to install", - failed_install: "failed to install", +const INSTALL_STATUS_PREDICATES: Record< + SoftwareInstallStatus | "pending", + string +> = { + pending: "pending", installed: "installed", - pending: "told Fleet to install", + uninstalled: "uninstalled", pending_install: "told Fleet to install", + failed_install: "failed to install", pending_uninstall: "told Fleet to uninstall", failed_uninstall: "failed to uninstall", } as const; @@ -306,10 +328,14 @@ export const getInstallStatusPredicate = (status: string | undefined) => { ); }; -export const INSTALL_STATUS_ICONS: Record = { +export const INSTALL_STATUS_ICONS: Record< + SoftwareInstallStatus | "pending" | "failed", + IconNames +> = { pending: "pending-outline", pending_install: "pending-outline", installed: "success-outline", + uninstalled: "success-outline", failed: "error-outline", failed_install: "error-outline", pending_uninstall: "pending-outline", diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index c9e6b08af9..47f08d4430 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -18,6 +18,7 @@ import FleetIcon from "components/icons/FleetIcon"; import { AppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/AppInstallDetails"; import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails"; +import SoftwareUninstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal"; import ActivityItem from "./ActivityItem"; import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal"; @@ -41,6 +42,10 @@ const ActivityFeed = ({ packageInstallDetails, setPackageInstallDetails, ] = useState(null); + const [ + packageUninstallDetails, + setPackageUninstallDetails, + ] = useState(null); const [ appInstallDetails, setAppInstallDetails, @@ -106,6 +111,9 @@ const ActivityFeed = ({ case ActivityType.InstalledSoftware: setPackageInstallDetails({ ...details }); break; + case ActivityType.UninstalledSoftware: + setPackageUninstallDetails({ ...details }); + break; case ActivityType.InstalledAppStoreApp: setAppInstallDetails({ ...details }); break; @@ -205,6 +213,12 @@ const ActivityFeed = ({ onCancel={() => setPackageInstallDetails(null)} /> )} + {packageUninstallDetails && ( + setPackageUninstallDetails(null)} + /> + )} {appInstallDetails && ( ); }, + uninstalledSoftware: ( + activity: IActivity, + onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void + ) => { + const { details } = activity; + if (!details) { + return TAGGED_TEMPLATES.defaultActivityTemplate(activity); + } + + const { host_display_name: hostName, software_title: title } = details; + const status = + details.status === "failed" ? "failed_uninstall" : details.status; + + const showSoftwarePackage = + !!details.software_package && + activity.type === ActivityType.InstalledSoftware; + + return ( + <> + {" "} + {getInstallStatusPredicate(status)} software {title} + {showSoftwarePackage && ` (${details.software_package})`} from{" "} + {hostName}.{" "} + + + ); + }, enabledVpp: (activity: IActivity) => { return ( <> @@ -1168,6 +1202,9 @@ const getDetail = ( case ActivityType.InstalledSoftware: { return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick); } + case ActivityType.UninstalledSoftware: { + return TAGGED_TEMPLATES.uninstalledSoftware(activity, onDetailsClick); + } case ActivityType.AddedAppStoreApp: { return TAGGED_TEMPLATES.addedAppStoreApp(activity); } @@ -1234,6 +1271,7 @@ const ActivityItem = ({ DEFAULT_ACTOR_DISPLAY ); case ActivityType.InstalledSoftware: + case ActivityType.UninstalledSoftware: case ActivityType.InstalledAppStoreApp: return activity.details?.self_service ? ( An end user diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 4906cb4b55..0229550957 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -86,8 +86,11 @@ interface IStatusDisplayOption { tooltip: React.ReactNode; } +// "pending" and "failed" each encompass both "_install" and "_uninstall" sub-statuses +type SoftwareInstallDisplayStatus = "installed" | "pending" | "failed"; + const STATUS_DISPLAY_OPTIONS: Record< - SoftwareInstallStatus, + SoftwareInstallDisplayStatus, IStatusDisplayOption > = { installed: { @@ -114,16 +117,6 @@ const STATUS_DISPLAY_OPTIONS: Record< ), }, - pending_install: { - displayName: "Pending", - iconName: "pending-outline", - tooltip: "Fleet will install software when these hosts come online.", - }, - pending_uninstall: { - displayName: "Pending", - iconName: "pending-outline", - tooltip: "Fleet will uninstall software when these hosts come online.", - }, failed: { displayName: "Failed", iconName: "error", @@ -135,21 +128,11 @@ const STATUS_DISPLAY_OPTIONS: Record< ), }, - failed_install: { - displayName: "Failed", - iconName: "error", - tooltip: "Fleet failed to install software on these hosts.", - }, - failed_uninstall: { - displayName: "Failed", - iconName: "error", - tooltip: "Fleet failed to uninstall software on these hosts.", - }, }; interface IPackageStatusCountProps { softwareId: number; - status: SoftwareInstallStatus; + status: SoftwareInstallDisplayStatus; count: number; teamId?: number; } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts index 04bf2d18d4..986497d160 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts @@ -1,10 +1,16 @@ import { IAppStoreApp, + ISoftwarePackage, ISoftwareTitleDetails, isSoftwarePackage, } from "interfaces/software"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; +const mergePackageStatuses = (packageStatuses: ISoftwarePackage["status"]) => ({ + installed: packageStatuses.installed, + pending: packageStatuses.pending_install + packageStatuses.pending_uninstall, + failed: packageStatuses.failed_install + packageStatuses.failed_uninstall, +}); /** * Generates the data needed to render the package card. */ @@ -24,7 +30,9 @@ export const getPackageCardInfo = (softwareTitle: ISoftwareTitleDetails) => { ? packageData.version : packageData.latest_version) || DEFAULT_EMPTY_CELL_VALUE, uploadedAt: isSoftwarePackage(packageData) ? packageData.uploaded_at : "", - status: packageData.status, + status: isSoftwarePackage(packageData) + ? mergePackageStatuses(packageData.status) + : packageData.status, isSelfService: packageData.self_service, }; }; diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx index c772d20f03..26476a8e89 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx @@ -16,8 +16,8 @@ import { IAddPackageFormData } from "../AddPackageForm/AddPackageForm"; const getSupportedScriptTypeText = (pkgType: PackageType) => { return `Currently, ${ - isWindowsPackageType(pkgType) ? "Power" : "" - }Shell scripts are supported.`; + isWindowsPackageType(pkgType) ? "PowerS" : "s" + }hell scripts are supported.`; }; const PKG_TYPE_TO_ID_TEXT = { diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 1a03eeeecf..2393f128ed 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -415,7 +415,7 @@ const DeviceUserPage = ({ (null); + const [ + packageUninstallDetails, + setPackageUninstallDetails, + ] = useState(null); const [ appInstallDetails, setAppInstallDetails, @@ -602,6 +607,13 @@ const HostDetailsPage = ({ host?.display_name || details?.host_display_name || "", }); break; + case "uninstalled_software": + setPackageUninstallDetails({ + ...details, + host_display_name: + host?.display_name || details?.host_display_name || "", + }); + break; case "installed_app_store_app": setAppInstallDetails({ ...details, @@ -933,9 +945,7 @@ const HostDetailsPage = ({ id={host.id} platform={host.platform} softwareUpdatedAt={host.software_updated_at} - hostCanInstallSoftware={ - !!host.orbit_version || isIosOrIpadosHost - } + hostCanWriteSoftware={!!host.orbit_version || isIosOrIpadosHost} isSoftwareEnabled={featuresConfig?.enable_software_inventory} router={router} queryParams={parseHostSoftwareQueryParams(location.query)} @@ -1065,6 +1075,12 @@ const HostDetailsPage = ({ onCancel={onCancelSoftwareInstallDetailsModal} /> )} + {packageUninstallDetails && ( + setPackageUninstallDetails(null)} + /> + )} {!!appInstallDetails && ( { const { actor_full_name: actorName, details } = activity; - const { self_service, status, software_title: title } = details; + const { self_service, software_title: title } = details; + const status = + details.status === "failed" ? "failed_uninstall" : details.status; const actorDisplayName = self_service ? ( An end user diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index 2a1c13b98b..63caddf9e0 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -441,7 +441,6 @@ const HostSummary = ({ }; const renderSummary = () => { - console.log(hostMdmProfiles); // for windows hosts we have to manually add a profile for disk encryption // as this is not currently included in the `profiles` value from the API // response for windows hosts. diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index a8a7c70284..1c56843986 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -37,7 +37,7 @@ interface IHostSoftwareProps { id: number | string; platform?: HostPlatform; softwareUpdatedAt?: string; - hostCanInstallSoftware: boolean; + hostCanWriteSoftware: boolean; router: InjectedRouter; queryParams: ReturnType; pathname: string; @@ -86,7 +86,7 @@ const HostSoftware = ({ id, platform, softwareUpdatedAt, - hostCanInstallSoftware, + hostCanWriteSoftware, router, queryParams, pathname, @@ -105,7 +105,8 @@ const HostSoftware = ({ isTeamMaintainer, } = useContext(AppContext); - const [installingSoftwareId, setInstallingSoftwareId] = useState< + // disables install/uninstall actions after click + const [softwareIdActionPending, setSoftwareIdActionPending] = useState< number | null >(null); @@ -175,13 +176,13 @@ const HostSoftware = ({ [isMyDevicePage, refetchDeviceSoftware, refetchHostSoftware] ); - const userHasSWInstallPermission = Boolean( + const userHasSWWritePermission = Boolean( isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer ); const installHostSoftwarePackage = useCallback( async (softwareId: number) => { - setInstallingSoftwareId(softwareId); + setSoftwareIdActionPending(softwareId); try { await hostAPI.installHostSoftwarePackage(id as number, softwareId); renderFlash( @@ -191,7 +192,28 @@ const HostSoftware = ({ } catch (e) { renderFlash("error", getErrorMessage(e)); } - setInstallingSoftwareId(null); + setSoftwareIdActionPending(null); + refetchSoftware(); + }, + [id, renderFlash, refetchSoftware] + ); + + const uninstallHostSoftwarePackage = useCallback( + async (softwareId: number) => { + setSoftwareIdActionPending(softwareId); + try { + await hostAPI.uninstallHostSoftwarePackage(id as number, softwareId); + renderFlash( + "success", + <> + Software is uninstalling or will uninstall when the host comes + online. To see details, go to Details > Activity. + + ); + } catch (e) { + renderFlash("error", "Couldn't uninstall. Please try again."); + } + setSoftwareIdActionPending(null); refetchSoftware(); }, [id, renderFlash, refetchSoftware] @@ -203,6 +225,9 @@ const HostSoftware = ({ case "install": installHostSoftwarePackage(software.id); break; + case "uninstall": + uninstallHostSoftwarePackage(software.id); + break; case "showDetails": onShowSoftwareDetails?.(software); break; @@ -210,7 +235,11 @@ const HostSoftware = ({ break; } }, - [installHostSoftwarePackage, onShowSoftwareDetails] + [ + installHostSoftwarePackage, + onShowSoftwareDetails, + uninstallHostSoftwarePackage, + ] ); const tableConfig = useMemo(() => { @@ -218,20 +247,20 @@ const HostSoftware = ({ ? generateDeviceSoftwareTableConfig() : generateHostSoftwareTableConfig({ router, - installingSoftwareId, - userHasSWInstallPermission, + softwareIdActionPending, + userHasSWWritePermission, onSelectAction, teamId: hostTeamId, - hostCanInstallSoftware, + hostCanWriteSoftware, }); }, [ isMyDevicePage, router, - installingSoftwareId, - userHasSWInstallPermission, + softwareIdActionPending, + userHasSWWritePermission, onSelectAction, hostTeamId, - hostCanInstallSoftware, + hostCanWriteSoftware, ]); const isLoading = isMyDevicePage diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 31c8a03a21..dd4aef833e 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -33,6 +33,7 @@ import InstallStatusCell from "./InstallStatusCell"; const DEFAULT_ACTION_OPTIONS: IDropdownOption[] = [ { value: "showDetails", label: "Show details", disabled: false }, { value: "install", label: "Install", disabled: false }, + { value: "uninstall", label: "Uninstall", disabled: false }, ]; type ISoftwareTableConfig = Column; @@ -50,17 +51,18 @@ type IInstalledVersionsCellProps = CellProps< type IVulnerabilitiesCellProps = IInstalledVersionsCellProps; const generateActions = ({ - userHasSWInstallPermission, - hostCanInstallSoftware, - installingSoftwareId, + userHasSWWritePermission, + // Commenting below in case there is a quick decision to use these conditions after all + // hostCanWriteSoftware, + // software_package, + softwareIdActionPending, softwareId, status, - software_package, app_store_app, }: { - userHasSWInstallPermission: boolean; - hostCanInstallSoftware: boolean; - installingSoftwareId: number | null; + userHasSWWritePermission: boolean; + hostCanWriteSoftware: boolean; + softwareIdActionPending: number | null; softwareId: number; status: SoftwareInstallStatus | null; software_package: IHostSoftwarePackage | null; @@ -76,39 +78,44 @@ const generateActions = ({ // error to fail loudly so that we know to update this function throw new Error("Install action not found in default actions"); } + const indexUninstallAction = actions.findIndex( + (a) => a.value === "uninstall" + ); + if (indexUninstallAction === -1) { + // this should never happen unless the default actions change, but if it does we'll throw an + // error to fail loudly so that we know to update this function + throw new Error("Uninstall action not found in default actions"); + } - const hasSoftwareToInstall = !!software_package || !!app_store_app; - // remove install if there is no package to install or if the software is already installed - if ( - !hasSoftwareToInstall || - !userHasSWInstallPermission || - status === "installed" - ) { + if (!userHasSWWritePermission) { actions.splice(indexInstallAction, 1); - return actions; + actions.splice(indexUninstallAction, 1); + } else { + // user has software write permission for host + const pendingStatuses = ["pending_install", "pending_uninstall"]; + + if ( + // if locally pending (waiting for API response) or pending install/uninstall, disable both + // install and uninstall + softwareId === softwareIdActionPending || + pendingStatuses.includes(status || "") + ) { + actions[indexInstallAction].disabled = true; + actions[indexUninstallAction].disabled = true; + } } - // disable install option if not a fleetd, iPad, or iOS host - if (!hostCanInstallSoftware) { - actions[indexInstallAction].disabled = true; - actions[indexInstallAction].tooltipContent = - "To install software on this host, deploy the fleetd agent with --enable-scripts and refetch host vitals."; - return actions; + if (app_store_app) { + // remove uninstall for VPP apps + actions.splice(indexUninstallAction, 1); } - - // disable install option if software is already installing - if (softwareId === installingSoftwareId || status === "pending") { - actions[indexInstallAction].disabled = true; - return actions; - } - return actions; }; interface ISoftwareTableHeadersProps { - userHasSWInstallPermission: boolean; - hostCanInstallSoftware: boolean; - installingSoftwareId: number | null; + userHasSWWritePermission: boolean; + hostCanWriteSoftware: boolean; + softwareIdActionPending: number | null; router: InjectedRouter; teamId: number; onSelectAction: (software: IHostSoftware, action: string) => void; @@ -117,9 +124,9 @@ interface ISoftwareTableHeadersProps { // NOTE: cellProps come from react-table // more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties export const generateSoftwareTableHeaders = ({ - userHasSWInstallPermission, - hostCanInstallSoftware, - installingSoftwareId, + userHasSWWritePermission, + hostCanWriteSoftware, + softwareIdActionPending, router, teamId, onSelectAction, @@ -209,9 +216,9 @@ export const generateSoftwareTableHeaders = ({ | "selfService", IStatusDisplayConfig > = { installed: { @@ -39,52 +39,42 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< tooltip: () => "Software is installed (install script finished with exit code 0).", }, - pending: { + pending_install: { iconName: "pending-outline", - displayText: "Pending", + displayText: "Installing (pending)", tooltip: () => "Fleet is installing or will install when the host comes online.", }, - pending_install: { - iconName: "pending-outline", - displayText: "Pending", - tooltip: () => "Fleet will install software when the host comes online.", - }, pending_uninstall: { iconName: "pending-outline", - displayText: "Pending", - tooltip: () => "Fleet will uninstall software when the host comes online.", - }, - failed: { - iconName: "error", - displayText: "Failed", + displayText: "Uninstalling (pending)", tooltip: () => ( <> - The host failed to install software. To view errors, select + Fleet is uninstalling or will uninstall
- Actions > Show details. + software when the host comes online. ), }, failed_install: { iconName: "error", - displayText: "Failed", - tooltip: ({ lastInstalledAt: lastInstall }) => ( + displayText: "Install (failed)", + tooltip: () => ( <> - The host failed to install software. To view errors, select + The host failed to install software.
- Actions > Show details. + Select Actions > Show details view errors. ), }, failed_uninstall: { iconName: "error", - displayText: "Failed", - tooltip: ({ lastInstalledAt: lastInstall }) => ( + displayText: "Uninstall (failed)", + tooltip: () => ( <> - The host failed to install software. To view errors, select + The host failed to uninstall software.
- Actions > Show details. + Select Details > Activity to view errors. ), }, diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx index a7ca5e5cdd..2b96a578c3 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx @@ -112,13 +112,13 @@ describe("SelfService", () => { ).toHaveTextContent("Reinstall"); }); - it("renders 'Retry' action button with 'Failed' status", async () => { + it("renders 'Retry' action button with 'failed_install' status", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ createMockDeviceSoftware({ name: "test-software", - status: "failed", + status: "failed_install", }), ], }) @@ -166,13 +166,13 @@ describe("SelfService", () => { ).toHaveTextContent("Install"); }); - it("renders no action button with 'Pending' status", async () => { + it("renders no action button with 'pending_install' status", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ createMockDeviceSoftware({ name: "test-software", - status: "pending", + status: "pending_install", }), ], }) diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx index 90a58c8142..f2907e693f 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -21,39 +21,24 @@ import { IStatusDisplayConfig } from "../../InstallStatusCell/InstallStatusCell" const baseClass = "self-service-item"; -const STATUS_CONFIG: Record = { +const STATUS_CONFIG: Record< + Exclude< + SoftwareInstallStatus, + "pending_uninstall" | "failed_uninstall" | "uninstalled" + >, + IStatusDisplayConfig +> = { installed: { iconName: "success", displayText: "Installed", tooltip: ({ lastInstalledAt }) => `Software is installed (${dateAgo(lastInstalledAt as string)}).`, }, - pending: { + pending_install: { iconName: "pending-outline", displayText: "Pending", tooltip: () => "Fleet is installing software.", }, - pending_install: { - iconName: "pending-outline", - displayText: "Install in progress...", - tooltip: () => "Software installation in progress...", - }, - pending_uninstall: { - iconName: "pending-outline", - displayText: "Uninstall in progress...", - tooltip: () => "Software uninstallation in progress...", - }, - failed: { - iconName: "error", - displayText: "Failed", - tooltip: ({ lastInstalledAt = "" }) => ( - <> - Software failed to install{" "} - {lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "} - Retry to install again, or contact your IT department. - - ), - }, failed_install: { iconName: "error", displayText: "Failed", @@ -65,17 +50,6 @@ const STATUS_CONFIG: Record = { ), }, - failed_uninstall: { - iconName: "error", - displayText: "Failed", - tooltip: ({ lastInstalledAt = "" }) => ( - <> - Software failed to install - {lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "} - Retry to install again, or contact your IT department. - - ), - }, }; interface IInstallerInfoProps { @@ -166,7 +140,7 @@ const getInstallButtonText = (status: SoftwareInstallStatus | null) => { switch (status) { case null: return "Install"; - case "failed": + case "failed_install": return "Retry"; case "installed": return "Reinstall"; @@ -195,7 +169,7 @@ const InstallerStatusAction = ({ // if the localStatus is "failed", we don't want our tooltip to include the old installed_at date so we // set this to null, which tells the tooltip to omit the parenthetical date - const lastInstall = localStatus === "failed" ? null : last_install; + const lastInstall = localStatus === "failed_install" ? null : last_install; const isMountedRef = useRef(false); useEffect(() => { @@ -206,7 +180,7 @@ const InstallerStatusAction = ({ }, []); const onClick = useCallback(async () => { - setLocalStatus("pending"); + setLocalStatus("pending_install"); try { await deviceApi.installSelfServiceSoftware(deviceToken, id); if (isMountedRef.current) { @@ -215,7 +189,7 @@ const InstallerStatusAction = ({ } catch (error) { renderFlash("error", "Couldn't install. Please try again."); if (isMountedRef.current) { - setLocalStatus("failed"); + setLocalStatus("failed_install"); } } }, [deviceToken, id, onInstall, renderFlash]); @@ -232,7 +206,7 @@ const InstallerStatusAction = ({ type="button" className={`${baseClass}__item-action-button`} onClick={onClick} - disabled={localStatus === "pending"} + disabled={localStatus === "pending_install"} > {installButtonText} diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index ba7e0dc7ab..eca209aab4 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -590,4 +590,11 @@ export default { HOST_SOFTWARE_PACKAGE_INSTALL(hostId, softwareId) ); }, + uninstallHostSoftwarePackage: (hostId: number, softwareId: number) => { + const { HOST_SOFTWARE_PACKAGE_UNINSTALL } = endpoints; + return sendRequest( + "POST", + HOST_SOFTWARE_PACKAGE_UNINSTALL(hostId, softwareId) + ); + }, }; diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts index 8ed35f9a17..6f5792cd25 100644 --- a/frontend/services/entities/scripts.ts +++ b/frontend/services/entities/scripts.ts @@ -39,6 +39,7 @@ export interface IScriptResultResponse { message: string; runtime: number; host_timeout: boolean; + created_at: string; } /** diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 2db7880932..94e1da1cb6 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -219,6 +219,8 @@ export default { formData.append("software", data.software); formData.append("self_service", data.selfService.toString()); data.installScript && formData.append("install_script", data.installScript); + data.uninstallScript && + formData.append("uninstall_script", data.uninstallScript); data.preInstallQuery && formData.append("pre_install_query", data.preInstallQuery); data.postInstallScript && diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 2abe094de1..0eadb2d2c8 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -52,7 +52,9 @@ export default { `/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/resend/${profileUUID}`, HOST_SOFTWARE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/software`, HOST_SOFTWARE_PACKAGE_INSTALL: (hostId: number, softwareId: number) => - `/${API_VERSION}/fleet/hosts/${hostId}/software/install/${softwareId}`, + `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/install`, + HOST_SOFTWARE_PACKAGE_UNINSTALL: (hostId: number, softwareId: number) => + `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/uninstall`, INVITES: `/${API_VERSION}/fleet/invites`, @@ -165,7 +167,7 @@ export default { SOFTWARE_PACKAGE_TOKEN: (id: number) => `/${API_VERSION}/fleet/software/titles/${id}/package/token`, SOFTWARE_INSTALL_RESULTS: (uuid: string) => - `/${API_VERSION}/fleet/software/install/results/${uuid}`, + `/${API_VERSION}/fleet/software/install/${uuid}/results`, SOFTWARE_PACKAGE_INSTALL: (id: number) => `/${API_VERSION}/fleet/software/packages/${id}`, SOFTWARE_AVAILABLE_FOR_INSTALL: (id: number) =>