diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 77521c2c30..192454d638 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -545,29 +545,87 @@ export interface IHostSoftware { * - Cases where the software inventory has not yet updated to reflect a recent change * (i.e., last_install date vs host software's updated_at date) */ +// Error UI statuses +export const HOST_SOFTWARE_UI_ERROR_STATUSES = [ + "failed_install", // Install attempt failed + "failed_install_update_available", // Install/update failed; newer installer version available + "failed_uninstall", // Uninstall attempt failed + "failed_uninstall_update_available", // Uninstall/update failed; newer installer version available + "failed_script", // Script package failed to run +] as const; +export type HostSoftwareUiErrorStatus = typeof HOST_SOFTWARE_UI_ERROR_STATUSES[number]; +export const isSoftwareErrorStatus = ( + status: IHostSoftwareUiStatus +): status is HostSoftwareUiErrorStatus => + HOST_SOFTWARE_UI_ERROR_STATUSES.includes(status as HostSoftwareUiErrorStatus); + +// Pending UI statuses for OFFLINE hosts +export const HOST_SOFTWARE_UI_PENDING_STATUSES = [ + "pending_install", // Install scheduled (no newer installer version) + "pending_uninstall", // Uninstall scheduled + "pending_update", // Update scheduled (no newer installer version) + "pending_script", // Fleet-initiated script run scheduled +] as const; +export type HostSoftwareUiPendingStatus = typeof HOST_SOFTWARE_UI_PENDING_STATUSES[number]; +export const isSoftwarePendingStatus = ( + status: IHostSoftwareUiStatus +): status is HostSoftwareUiPendingStatus => + HOST_SOFTWARE_UI_PENDING_STATUSES.includes( + status as HostSoftwareUiPendingStatus + ); + +// In-progress UI statuses for ONLINE hosts +export const HOST_SOFTWARE_UI_IN_PROGRESS_STATUSES = [ + "installing", // Fleet-initiated install in progress + "updating", // Update (install) in progress with newer fleet installer + "uninstalling", // Fleet-initiated uninstall in progress + "running_script", // Fleet-initiated script run in progress +] as const; +export type HostSoftwareUiInProgressStatus = typeof HOST_SOFTWARE_UI_IN_PROGRESS_STATUSES[number]; +export const isSoftwareInProgressStatus = ( + status: IHostSoftwareUiStatus +): status is HostSoftwareUiInProgressStatus => + HOST_SOFTWARE_UI_IN_PROGRESS_STATUSES.includes( + status as HostSoftwareUiInProgressStatus + ); + +// Success/steady-state UI statuses +export const HOST_SOFTWARE_UI_SUCCESS_STATUSES = [ + "installed", // Present in inventory; no newer fleet installer version (tarballs: successful install only) + "uninstalled", // Not present in inventory (tarballs: successful uninstall or never installed) + "recently_updated", // Update applied (installer newer than inventory), but inventory not yet refreshed + "recently_installed", // Install applied (installer NOT newer than inventory), but inventory not yet refreshed + "recently_uninstalled", // Uninstall applied, but inventory not yet refreshed + "ran_script", // Script package ran successfully + "never_ran_script", // Script package never ran before +] as const; +export type HostSoftwareUiSuccessStatus = typeof HOST_SOFTWARE_UI_SUCCESS_STATUSES[number]; +export const isSoftwareSuccessStatus = ( + status: IHostSoftwareUiStatus +): status is HostSoftwareUiSuccessStatus => + HOST_SOFTWARE_UI_SUCCESS_STATUSES.includes( + status as HostSoftwareUiSuccessStatus + ); + +// Update-available UI status +export const HOST_SOFTWARE_UI_UPDATE_AVAILABLE_STATUSES = [ + "update_available", // In inventory, but newer fleet installer version is available +] as const; +export type HostSoftwareUiUpdateAvailableStatus = typeof HOST_SOFTWARE_UI_UPDATE_AVAILABLE_STATUSES[number]; +export const isSoftwareUpdateAvailableStatus = ( + status: IHostSoftwareUiStatus +): status is HostSoftwareUiUpdateAvailableStatus => + HOST_SOFTWARE_UI_UPDATE_AVAILABLE_STATUSES.includes( + status as HostSoftwareUiUpdateAvailableStatus + ); + +// Master UI status type, combining all: export type IHostSoftwareUiStatus = - | "installed" // Present in inventory; no newer fleet installer version (tarballs: successful install only) - | "uninstalled" // Not present in inventory (tarballs: successful uninstall or never installed) - | "installing" // ONLINE; fleet-initiated install in progress - | "uninstalling" // ONLINE; fleet-initiated uninstall in progress - | "recently_updated" // Update applied (installer newer than inventory), but inventory not yet refreshed - | "recently_installed" // Install applied (installer NOT newer than inventory), but inventory not yet refreshed - | "recently_uninstalled" // Uninstall applied, but inventory not yet refreshed - | "updating" // ONLINE; update (install) in progress with newer fleet installer - | "pending_install" // OFFLINE; install scheduled (no newer installer version) - | "pending_uninstall" // OFFLINE; uninstall scheduled - | "pending_update" // OFFLINE; update scheduled (no newer installer version) - | "failed_install" // Install attempt failed - | "failed_install_update_available" // Install/update failed; newer installer version available - | "failed_uninstall" // Uninstall attempt failed - | "failed_uninstall_update_available" // Uninstall/update failed; newer installer version available - | "update_available" // In inventory, but newer fleet installer version is available - // Script UI statuses - | "ran_script" // Script package ran successfully - | "failed_script" // Script package failed to run - | "running_script" // ONLINE; fleet-initiated script run in progress - | "pending_script" // OFFLINE; fleet-initiated script run scheduled - | "never_ran_script"; // Script package never ran before + | HostSoftwareUiErrorStatus + | HostSoftwareUiPendingStatus + | HostSoftwareUiSuccessStatus + | HostSoftwareUiInProgressStatus + | HostSoftwareUiUpdateAvailableStatus; /** * Extends IHostSoftware with a computed `ui_status` field. diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx index 8fca0947f5..a0375bcaf2 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx @@ -350,6 +350,7 @@ const PackageForm = ({ isExePackage={isExePackage} isTarballPackage={isTarballPackage} isScriptPackage={isScriptPackage} + isIpaPackage={isIpaPackage} onClickPreviewEndUserExperience={() => onClickPreviewEndUserExperience(isIpaPackage) } diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx index 2cf1db359a..0c591f8ab3 100644 --- a/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx @@ -42,8 +42,9 @@ const CategoriesSelector = ({ value={selectedCategories.includes(cat.value)} onChange={onSelectCategory} parseTarget - /> -
{cat.label}
+ > +
{cat.label}
+ ); })} diff --git a/frontend/pages/hosts/details/cards/HostSoftwareLibrary/_styles.scss b/frontend/pages/hosts/details/cards/HostSoftwareLibrary/_styles.scss index f9679c0c01..766fd00c64 100644 --- a/frontend/pages/hosts/details/cards/HostSoftwareLibrary/_styles.scss +++ b/frontend/pages/hosts/details/cards/HostSoftwareLibrary/_styles.scss @@ -53,7 +53,7 @@ } &__item-action { - min-width: 90px; + min-width: 94px; // Fits the "Reinstall" button without shifting .component__tooltip-wrapper { margin: 0; // Override 10px vertical margin diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tests.tsx index 520ce9ca0c..2a47fd74ff 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tests.tsx @@ -11,7 +11,9 @@ const makeSoftware = (ui_status: string) => ...createMockHostSoftware(), ui_status, } as IDeviceSoftwareWithUiStatus); + describe("TileActionStatus", () => { + // Active/pending/running statuses it("shows spinner and installing label if installing", () => { render( { expect(screen.getByTestId("spinner")).toBeInTheDocument(); }); + it("shows spinner and installing label if pending_install", () => { + render( + + ); + expect(screen.getByText(/Installing.../i)).toBeInTheDocument(); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + it("shows spinner and updating label if updating", () => { render( { expect(screen.getByTestId("spinner")).toBeInTheDocument(); }); + it("shows spinner and updating label if pending_update", () => { + render( + + ); + expect(screen.getByText(/Updating.../i)).toBeInTheDocument(); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("shows spinner and running label if running_script", () => { + render( + + ); + expect(screen.getByText(/Running.../i)).toBeInTheDocument(); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("shows spinner and running label if pending_script", () => { + render( + + ); + expect(screen.getByText(/Running.../i)).toBeInTheDocument(); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("shows spinner and uninstalling label if uninstalling", () => { + render( + + ); + expect(screen.getByText(/Uninstalling.../i)).toBeInTheDocument(); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("shows spinner and uninstalling label if pending_uninstall", () => { + render( + + ); + expect(screen.getByText(/Uninstalling.../i)).toBeInTheDocument(); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + // Button/status tests it("shows Install button for uninstalled status", () => { const onClick = jest.fn(); render( @@ -48,6 +117,18 @@ describe("TileActionStatus", () => { expect(onClick).toHaveBeenCalled(); }); + it("shows Install button for recently_uninstalled status", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /Install/i }) + ).toBeInTheDocument(); + }); + it("shows Update button for update_available status", () => { render( { expect(screen.getByRole("button", { name: /Update/i })).toBeInTheDocument(); }); + it("shows Update button for failed_uninstall_update_available status", () => { + render( + + ); + expect(screen.getByRole("button", { name: /Update/i })).toBeInTheDocument(); + }); + it("shows Reinstall button for installed status", () => { render( { ).toBeInTheDocument(); }); + it("shows Reinstall button for recently_installed status", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /Reinstall/i }) + ).toBeInTheDocument(); + }); + + it("shows Reinstall button for recently_updated status", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /Reinstall/i }) + ).toBeInTheDocument(); + }); + + it("shows Reinstall button for failed_uninstall status", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /Reinstall/i }) + ).toBeInTheDocument(); + }); + + it("shows Run button for never_ran_script status", () => { + render( + + ); + expect(screen.getByRole("button", { name: /Run/i })).toBeInTheDocument(); + }); + + it("shows Rerun button for ran_script status", () => { + render( + + ); + expect(screen.getByRole("button", { name: /Rerun/i })).toBeInTheDocument(); + }); + it("shows Retry button and error display for failed_install", () => { render( { expect(screen.getByTestId("error-icon")).toBeInTheDocument(); }); - it("renders nothing if tile label is null", () => { + it("shows Retry button for failed_script status", () => { + render( + + ); + expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument(); + }); + + // Unknown/fallback status + it("shows Install button for unknown_status (default)", () => { render( ); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); - expect(screen.queryByText(/Failed/i)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Install/i }) + ).toBeInTheDocument(); }); }); diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tsx index 817ff05847..79e4e64946 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/components/TileActionStatus/TileActionStatus.tsx @@ -1,5 +1,11 @@ import React from "react"; -import { IDeviceSoftwareWithUiStatus } from "interfaces/software"; +import { + IDeviceSoftwareWithUiStatus, + IHostSoftwareUiStatus, + isSoftwareErrorStatus, + isSoftwareInProgressStatus, + isSoftwarePendingStatus, +} from "interfaces/software"; import Button from "components/buttons/Button"; import Icon from "components/Icon"; import Spinner from "components/Spinner"; @@ -11,42 +17,69 @@ interface TileActionStatusProps { onActionClick: (software: IDeviceSoftwareWithUiStatus) => void; } -const getTileActionLabel = (software: IDeviceSoftwareWithUiStatus) => { - if (software.ui_status === "uninstalled") { - return "Install"; +const getTileActionLabel = (uiStatus: IHostSoftwareUiStatus): string | null => { + switch (uiStatus) { + case "uninstalled": + case "recently_uninstalled": + return "Install"; + case "failed_install": + case "failed_install_update_available": + case "failed_script": + return "Retry"; + case "update_available": + case "failed_uninstall_update_available": + return "Update"; + case "installed": + case "recently_installed": + case "recently_updated": + case "failed_uninstall": // Mobile UI only shows install action despite ui_status relating to uninstall + return "Reinstall"; + case "never_ran_script": + return "Run"; + case "ran_script": + return "Rerun"; + default: + return "Install"; } - if ( - software.ui_status === "failed_install" || - software.ui_status === "failed_install_update_available" - ) { - return "Retry"; +}; + +const getPendingOrRunningLabel = ( + uiStatus: IHostSoftwareUiStatus +): string | null => { + switch (uiStatus) { + case "updating": + case "pending_update": + return "Updating..."; + case "installing": + case "pending_install": + return "Installing..."; + case "running_script": + case "pending_script": + return "Running..."; + case "uninstalling": + case "pending_uninstall": + return "Uninstalling..."; + default: + return null; } - if (software.ui_status === "update_available") { - return "Update"; - } - if (software.ui_status === "installed") { - return "Reinstall"; - } - return null; }; const TileActionStatus = ({ software, onActionClick, }: TileActionStatusProps) => { - const actionLabel = getTileActionLabel(software); - const isError = - software.ui_status === "failed_install" || - software.ui_status === "failed_install_update_available"; + const actionLabel = getTileActionLabel(software.ui_status); + const isError = isSoftwareErrorStatus(software.ui_status); const isActiveAction = - software.ui_status === "updating" || software.ui_status === "installing"; + isSoftwareInProgressStatus(software.ui_status) || + isSoftwarePendingStatus(software.ui_status); const renderActiveActionStatus = () => { return ( <> - {software.ui_status === "updating" ? "Updating..." : "Installing..."} + {getPendingOrRunningLabel(software.ui_status)} ); }; diff --git a/frontend/pages/hosts/details/cards/Software/helpers.tsx b/frontend/pages/hosts/details/cards/Software/helpers.tsx index 673b9db920..0fbcf7f870 100644 --- a/frontend/pages/hosts/details/cards/Software/helpers.tsx +++ b/frontend/pages/hosts/details/cards/Software/helpers.tsx @@ -338,6 +338,8 @@ export const getInstallerActionButtonConfig = ( case "pending_uninstall": case "uninstalling": case "failed_uninstall": + case "recently_installed": + case "recently_updated": return { text: "Reinstall", icon: "refresh" }; case "pending_update": case "updating":