mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Fleet UI: Allow self-service uninstallations of FMA and custom packages (#29055)
This commit is contained in:
parent
714337163e
commit
56b34eb29f
13 changed files with 529 additions and 118 deletions
|
|
@ -58,7 +58,7 @@ const STATUS_DISPLAY_OPTIONS: Record<
|
|||
iconName: "success",
|
||||
tooltip: (
|
||||
<>
|
||||
Software is installed on these hosts (install script finished
|
||||
Software was installed on these hosts (install script finished
|
||||
<br />
|
||||
with exit code 0). Currently, if the software is uninstalled, the
|
||||
<br />
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe("InstallStatusCell - component", () => {
|
|||
expect(screen.getByText("Installed")).toBeInTheDocument();
|
||||
|
||||
await user.hover(screen.getByText("Installed"));
|
||||
expect(screen.getByText(/Software is installed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Software was installed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'Installing (pending)' status with tooltip", async () => {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
Software is installed (install
|
||||
Software was installed (install
|
||||
<br />
|
||||
script finished with exit code 0).
|
||||
</>
|
||||
|
|
@ -101,13 +101,14 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
|
|||
tooltip: ({ softwareName, isAppStoreApp }) =>
|
||||
isAppStoreApp ? (
|
||||
<>
|
||||
App Store app can be installed on the host. Select{" "}
|
||||
<b>Actions {">"} Install</b> to install.
|
||||
App Store app can be installed on the host. <br />
|
||||
Select <b>Actions {">"} Install</b> to install.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{softwareName ? <b>{softwareName}</b> : "Software"} can be installed
|
||||
on the host. Select <b>Actions > Install</b> to install.
|
||||
on the host.
|
||||
<br /> Select <b>Actions > Install</b> to install.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
@ -117,7 +118,9 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
|
|||
tooltip: ({ softwareName }) => (
|
||||
<>
|
||||
{softwareName ? <b>{softwareName}</b> : "Software"} can be installed on
|
||||
the host. {SELF_SERVICE_TOOLTIP}
|
||||
the host.
|
||||
<br />
|
||||
{SELF_SERVICE_TOOLTIP}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe("SelfService", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("renders 'Reinstall' action button with 'Installed' status", async () => {
|
||||
it("renders installed status and 'Reinstall' and 'Uninstall' action buttons with 'installed'", async () => {
|
||||
mockServer.use(
|
||||
customDeviceSoftwareHandler({
|
||||
software: [
|
||||
|
|
@ -117,12 +117,11 @@ describe("SelfService", () => {
|
|||
screen.getByTestId("self-service-table__status--test")
|
||||
).toHaveTextContent("Installed");
|
||||
|
||||
expect(
|
||||
screen.getByTestId("self-service-table__action-button--test")
|
||||
).toHaveTextContent("Reinstall");
|
||||
expect(screen.getByRole("button", { name: "Reinstall" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Uninstall" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders 'Retry' action button with 'failed_install' status", async () => {
|
||||
it("renders failed status and 'Retry' and 'Uninstall' action buttons with 'failed_install'", async () => {
|
||||
mockServer.use(
|
||||
customDeviceSoftwareHandler({
|
||||
software: [
|
||||
|
|
@ -144,12 +143,39 @@ describe("SelfService", () => {
|
|||
screen.getByTestId("self-service-table__status--test")
|
||||
).toHaveTextContent("Failed");
|
||||
|
||||
expect(
|
||||
screen.getByTestId("self-service-table__action-button--test")
|
||||
).toHaveTextContent("Retry");
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Uninstall" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders 'Install' action button with no status", async () => {
|
||||
it("renders failed status and 'Install' and 'Retry uninstall' action buttons with 'failed_uninstall' status", async () => {
|
||||
mockServer.use(
|
||||
customDeviceSoftwareHandler({
|
||||
software: [
|
||||
createMockDeviceSoftware({
|
||||
name: "test-software",
|
||||
status: "failed_uninstall",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const render = createCustomRenderer({ withBackendMock: true });
|
||||
render(<SelfService {...TEST_PROPS} />);
|
||||
|
||||
// waiting for the device software data to render
|
||||
await screen.findByText("test-software");
|
||||
|
||||
expect(
|
||||
screen.getByTestId("self-service-table__status--test")
|
||||
).toHaveTextContent("Failed");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Install" })).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Retry uninstall" })
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders no status and 'Install' and 'Uninstall' action buttons with no API status", async () => {
|
||||
mockServer.use(
|
||||
customDeviceSoftwareHandler({
|
||||
software: [
|
||||
|
|
@ -171,12 +197,11 @@ describe("SelfService", () => {
|
|||
screen.queryByTestId("self-service-table__status--test")
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("self-service-table__action-button--test")
|
||||
).toHaveTextContent("Install");
|
||||
expect(screen.getByRole("button", { name: "Install" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Uninstall" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders no action button with 'pending_install' status", async () => {
|
||||
it("renders installing status and disables action buttons with 'pending_install'", async () => {
|
||||
mockServer.use(
|
||||
customDeviceSoftwareHandler({
|
||||
software: [
|
||||
|
|
@ -196,10 +221,35 @@ describe("SelfService", () => {
|
|||
|
||||
expect(
|
||||
screen.getByTestId("self-service-table__status--test")
|
||||
).toHaveTextContent(/Installing.../i);
|
||||
).toHaveTextContent("Installing...");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Install" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Uninstall" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders uninstalling status and disables 'Reinstall' and 'Uninstall' action buttons with 'pending_uninstall'", async () => {
|
||||
mockServer.use(
|
||||
customDeviceSoftwareHandler({
|
||||
software: [
|
||||
createMockDeviceSoftware({
|
||||
name: "test-software",
|
||||
status: "pending_uninstall",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const render = createCustomRenderer({ withBackendMock: true });
|
||||
render(<SelfService {...TEST_PROPS} />);
|
||||
|
||||
// waiting for the device software data to render
|
||||
await screen.findAllByText("test-software");
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("self-service-table__action-button--test")
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByTestId("self-service-table__status--test")
|
||||
).toHaveTextContent("Uninstalling...");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Reinstall" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Uninstall" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import deviceApi, {
|
|||
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
import { getPathWithQueryParams } from "utilities/url";
|
||||
import { getExtensionFromFileName } from "utilities/file/fileUtils";
|
||||
|
||||
import { SingleValue } from "react-select-5";
|
||||
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
|
||||
|
|
@ -34,6 +35,7 @@ import SearchField from "components/forms/fields/SearchField";
|
|||
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
|
||||
import Pagination from "components/Pagination";
|
||||
|
||||
import UninstallSoftwareModal from "./UninstallSoftwareModal";
|
||||
import {
|
||||
InstallOrCommandUuid,
|
||||
generateSoftwareTableHeaders as generateDeviceSoftwareTableConfig,
|
||||
|
|
@ -82,6 +84,16 @@ const SoftwareSelfService = ({
|
|||
const [selfServiceData, setSelfServiceData] = useState<
|
||||
IGetDeviceSoftwareResponse | undefined
|
||||
>(undefined);
|
||||
const [showUninstallSoftwareModal, setShowUninstallSoftwareModal] = useState(
|
||||
false
|
||||
);
|
||||
|
||||
const selectedSoftware = useRef<{
|
||||
softwareId: number;
|
||||
softwareName: string;
|
||||
softwareInstallerType?: string;
|
||||
version: string;
|
||||
} | null>(null);
|
||||
|
||||
const pendingSoftwareSetRef = useRef<Set<string>>(new Set()); // Track for polling
|
||||
const pollingTimeoutIdRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
|
@ -113,8 +125,8 @@ const SoftwareSelfService = ({
|
|||
},
|
||||
});
|
||||
|
||||
// Poll for pending installs
|
||||
const { refetch: refetchForPendingInstalls } = useQuery<
|
||||
// Poll for pending installs/uninstalls
|
||||
const { refetch: refetchForPendingInstallsOrUninstalls } = useQuery<
|
||||
IGetDeviceSoftwareResponse,
|
||||
AxiosError
|
||||
>(
|
||||
|
|
@ -126,7 +138,11 @@ const SoftwareSelfService = ({
|
|||
// Get the set of pending software IDs
|
||||
const newPendingSet = new Set(
|
||||
response.software
|
||||
.filter((software) => software.status === "pending_install")
|
||||
.filter(
|
||||
(software) =>
|
||||
software.status === "pending_install" ||
|
||||
software.status === "pending_uninstall"
|
||||
)
|
||||
.map((software) => String(software.id))
|
||||
);
|
||||
|
||||
|
|
@ -149,10 +165,10 @@ const SoftwareSelfService = ({
|
|||
clearTimeout(pollingTimeoutIdRef.current);
|
||||
}
|
||||
pollingTimeoutIdRef.current = setTimeout(() => {
|
||||
refetchForPendingInstalls();
|
||||
refetchForPendingInstallsOrUninstalls();
|
||||
}, 5000);
|
||||
} else {
|
||||
// No pending installs, stop polling and refresh data
|
||||
// No pending installs nor pending uninstalls, stop polling and refresh data
|
||||
pendingSoftwareSetRef.current = new Set();
|
||||
if (pollingTimeoutIdRef.current) {
|
||||
clearTimeout(pollingTimeoutIdRef.current);
|
||||
|
|
@ -171,7 +187,7 @@ const SoftwareSelfService = ({
|
|||
}
|
||||
);
|
||||
|
||||
const startPollingForPendingInstalls = useCallback(
|
||||
const startPollingForPendingInstallsOrUninstalls = useCallback(
|
||||
(pendingIds: string[]) => {
|
||||
const newSet = new Set(pendingIds);
|
||||
const setsAreEqual =
|
||||
|
|
@ -184,10 +200,10 @@ const SoftwareSelfService = ({
|
|||
if (pollingTimeoutIdRef.current) {
|
||||
clearTimeout(pollingTimeoutIdRef.current);
|
||||
}
|
||||
refetchForPendingInstalls(); // Starts polling for pending installs
|
||||
refetchForPendingInstallsOrUninstalls(); // Starts polling for pending installs
|
||||
}
|
||||
},
|
||||
[refetchForPendingInstalls]
|
||||
[refetchForPendingInstallsOrUninstalls]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
|
|
@ -201,20 +217,22 @@ const SoftwareSelfService = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// On initial load or data change, check for pending installs
|
||||
// On initial load or data change, check for pending installs/uninstalls
|
||||
useEffect(() => {
|
||||
const pendingSoftware = selfServiceData?.software.filter(
|
||||
(software) => software.status === "pending_install"
|
||||
(software) =>
|
||||
software.status === "pending_install" ||
|
||||
software.status === "pending_uninstall"
|
||||
);
|
||||
const pendingIds = pendingSoftware?.map((s) => String(s.id)) ?? [];
|
||||
if (pendingIds.length > 0) {
|
||||
startPollingForPendingInstalls(pendingIds);
|
||||
startPollingForPendingInstallsOrUninstalls(pendingIds);
|
||||
}
|
||||
}, [selfServiceData, startPollingForPendingInstalls]);
|
||||
}, [selfServiceData, startPollingForPendingInstallsOrUninstalls]);
|
||||
|
||||
const onInstall = useCallback(() => {
|
||||
refetchForPendingInstalls();
|
||||
}, [refetchForPendingInstalls]);
|
||||
const onInstallOrUninstall = useCallback(() => {
|
||||
refetchForPendingInstallsOrUninstalls();
|
||||
}, [refetchForPendingInstallsOrUninstalls]);
|
||||
|
||||
const onSearchQueryChange = (value: string) => {
|
||||
router.push(
|
||||
|
|
@ -238,6 +256,17 @@ const SoftwareSelfService = ({
|
|||
);
|
||||
};
|
||||
|
||||
const onExitUninstallSoftwareModal = () => {
|
||||
selectedSoftware.current = null;
|
||||
setShowUninstallSoftwareModal(false);
|
||||
};
|
||||
|
||||
const onSuccessUninstallSoftwareModal = () => {
|
||||
selectedSoftware.current = null;
|
||||
setShowUninstallSoftwareModal(false);
|
||||
onInstallOrUninstall;
|
||||
};
|
||||
|
||||
const onNextPage = useCallback(() => {
|
||||
router.push(
|
||||
getPathWithQueryParams(pathname, {
|
||||
|
|
@ -273,10 +302,21 @@ const SoftwareSelfService = ({
|
|||
const tableConfig = useMemo(() => {
|
||||
return generateDeviceSoftwareTableConfig({
|
||||
deviceToken,
|
||||
onInstall,
|
||||
onInstall: onInstallOrUninstall,
|
||||
onShowInstallerDetails,
|
||||
onClickUninstallAction: (software) => {
|
||||
selectedSoftware.current = {
|
||||
softwareId: software.id,
|
||||
softwareName: software.name,
|
||||
softwareInstallerType: getExtensionFromFileName(
|
||||
software.software_package?.name || ""
|
||||
),
|
||||
version: software.software_package?.version || "",
|
||||
};
|
||||
setShowUninstallSoftwareModal(true);
|
||||
},
|
||||
});
|
||||
}, [router]);
|
||||
}, [deviceToken, onInstallOrUninstall, onShowInstallerDetails]);
|
||||
|
||||
const renderSelfServiceCard = () => {
|
||||
const renderHeaderFilters = () => (
|
||||
|
|
@ -409,28 +449,41 @@ const SoftwareSelfService = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={baseClass}
|
||||
borderRadiusSize="xxlarge"
|
||||
paddingSize="xlarge"
|
||||
includeShadow
|
||||
>
|
||||
<CardHeader
|
||||
header="Self-service"
|
||||
subheader={
|
||||
<>
|
||||
Install organization-approved apps provided by your IT department.{" "}
|
||||
{contactUrl && (
|
||||
<span>
|
||||
If you need help,{" "}
|
||||
<CustomLink url={contactUrl} text="reach out to IT" newTab />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{renderSelfServiceCard()}
|
||||
</Card>
|
||||
<>
|
||||
<Card
|
||||
className={baseClass}
|
||||
borderRadiusSize="xxlarge"
|
||||
paddingSize="xlarge"
|
||||
includeShadow
|
||||
>
|
||||
<CardHeader
|
||||
header="Self-service"
|
||||
subheader={
|
||||
<>
|
||||
Install organization-approved apps provided by your IT department.{" "}
|
||||
{contactUrl && (
|
||||
<span>
|
||||
If you need help,{" "}
|
||||
<CustomLink url={contactUrl} text="reach out to IT" newTab />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{renderSelfServiceCard()}
|
||||
</Card>
|
||||
{showUninstallSoftwareModal && selectedSoftware.current && (
|
||||
<UninstallSoftwareModal
|
||||
softwareId={selectedSoftware.current.softwareId}
|
||||
softwareName={selectedSoftware.current.softwareName}
|
||||
softwareInstallerType={selectedSoftware.current.softwareInstallerType}
|
||||
version={selectedSoftware.current.version}
|
||||
token={deviceToken}
|
||||
onExit={onExitUninstallSoftwareModal}
|
||||
onSuccess={onSuccessUninstallSoftwareModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import React, { useRef, useEffect, useCallback, useContext } from "react";
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { CellProps, Column } from "react-table";
|
||||
|
||||
import {
|
||||
|
|
@ -19,6 +25,7 @@ import { dateAgo } from "utilities/date_format";
|
|||
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
|
||||
import Spinner from "components/Spinner";
|
||||
import Icon from "components/Icon";
|
||||
import { IconNames } from "components/icons";
|
||||
import Button from "components/buttons/Button";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
|
||||
|
|
@ -35,18 +42,12 @@ type IVulnerabilitiesCellProps = IInstalledVersionsCellProps;
|
|||
|
||||
const baseClass = "self-service-table";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
Exclude<
|
||||
SoftwareInstallStatus,
|
||||
"pending_uninstall" | "failed_uninstall" | "uninstalled"
|
||||
>,
|
||||
IStatusDisplayConfig
|
||||
> = {
|
||||
const STATUS_CONFIG: Record<SoftwareInstallStatus, IStatusDisplayConfig> = {
|
||||
installed: {
|
||||
iconName: "success",
|
||||
displayText: "Installed",
|
||||
tooltip: ({ lastInstalledAt = null }) => {
|
||||
return `Software is installed${
|
||||
return `Software was installed${
|
||||
lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""
|
||||
}.`;
|
||||
},
|
||||
|
|
@ -67,6 +68,31 @@ const STATUS_CONFIG: Record<
|
|||
</>
|
||||
),
|
||||
},
|
||||
uninstalled: {
|
||||
iconName: "success",
|
||||
displayText: "Uninstalled",
|
||||
tooltip: ({ lastInstalledAt = null }) => {
|
||||
return `Software uninstalled${
|
||||
lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""
|
||||
}.`;
|
||||
},
|
||||
},
|
||||
pending_uninstall: {
|
||||
iconName: "pending-outline",
|
||||
displayText: "Uninstalling...",
|
||||
tooltip: () => "Fleet is uninstalling software.",
|
||||
},
|
||||
failed_uninstall: {
|
||||
iconName: "error",
|
||||
displayText: "Failed (uninstall)",
|
||||
tooltip: ({ lastInstalledAt = null }) => (
|
||||
<>
|
||||
Software failed to uninstall
|
||||
{lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "}
|
||||
<b>Retry</b> to uninstall again, or contact your IT department.
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
interface CommandUuid {
|
||||
|
|
@ -140,60 +166,117 @@ const InstallerStatus = ({
|
|||
);
|
||||
};
|
||||
|
||||
interface IInstallerStatusActionProps {
|
||||
interface IInstallerStatusActionsProps {
|
||||
deviceToken: string;
|
||||
software: IHostSoftware;
|
||||
onInstall: () => void;
|
||||
onInstallOrUninstall: () => void;
|
||||
onClickUninstallAction: (software: IHostSoftware) => void;
|
||||
}
|
||||
|
||||
interface DisplayActionItems {
|
||||
install: {
|
||||
text: string;
|
||||
icon: IconNames;
|
||||
};
|
||||
uninstall: {
|
||||
text: string;
|
||||
icon: IconNames;
|
||||
};
|
||||
}
|
||||
|
||||
const getInstallButtonText = (status: SoftwareInstallStatus | null) => {
|
||||
switch (status) {
|
||||
case null:
|
||||
return "Install";
|
||||
case "failed_install":
|
||||
return "Retry";
|
||||
case "installed":
|
||||
case "uninstalled":
|
||||
case "pending_uninstall":
|
||||
return "Reinstall";
|
||||
default:
|
||||
return "";
|
||||
// including null
|
||||
return "Install";
|
||||
}
|
||||
};
|
||||
|
||||
const getInstallButtonIcon = (status: SoftwareInstallStatus | null) => {
|
||||
switch (status) {
|
||||
case null:
|
||||
return "install";
|
||||
case "failed_install":
|
||||
return "refresh";
|
||||
case "installed":
|
||||
case "uninstalled":
|
||||
case "pending_uninstall":
|
||||
case "failed_uninstall":
|
||||
return "refresh";
|
||||
default:
|
||||
return undefined;
|
||||
// including null
|
||||
return "install";
|
||||
}
|
||||
};
|
||||
|
||||
const getUninstallButtonText = (status: SoftwareInstallStatus | null) => {
|
||||
switch (status) {
|
||||
case "failed_uninstall":
|
||||
return "Retry uninstall";
|
||||
default:
|
||||
// including null, "installed", "pending_install", "pending_uninstalled", "failed_install"
|
||||
return "Uninstall";
|
||||
}
|
||||
};
|
||||
|
||||
const getUninstallButtonIcon = (status: SoftwareInstallStatus | null) => {
|
||||
switch (status) {
|
||||
case "failed_uninstall":
|
||||
return "refresh";
|
||||
default:
|
||||
// including null, "installed", "pending_install", "pending_uninstalled", "failed_install"
|
||||
return "trash";
|
||||
}
|
||||
};
|
||||
|
||||
const InstallerStatusAction = ({
|
||||
deviceToken,
|
||||
software: { id, status, software_package, app_store_app },
|
||||
onInstall,
|
||||
}: IInstallerStatusActionProps) => {
|
||||
onInstallOrUninstall,
|
||||
onClickUninstallAction,
|
||||
}: IInstallerStatusActionsProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
// TODO: update this if/when we support self-service app store apps
|
||||
const last_install =
|
||||
software_package?.last_install ?? app_store_app?.last_install ?? null;
|
||||
// displayActionItems is used to track the display text and icons of the install and uninstall button
|
||||
const [
|
||||
displayActionItems,
|
||||
setDisplayActionItems,
|
||||
] = useState<DisplayActionItems>({
|
||||
install: {
|
||||
text: getInstallButtonText(status),
|
||||
icon: getInstallButtonIcon(status),
|
||||
},
|
||||
uninstall: {
|
||||
text: getUninstallButtonText(status),
|
||||
icon: getUninstallButtonIcon(status),
|
||||
},
|
||||
});
|
||||
|
||||
// localStatus is used to track the status of the any user-initiated install action
|
||||
const [localStatus, setLocalStatus] = React.useState<
|
||||
SoftwareInstallStatus | undefined
|
||||
>(undefined);
|
||||
useEffect(() => {
|
||||
// We update the text/icon only when we see a change to a non-pending status
|
||||
// Pending statuses keep the original text shown (e.g. "Retry" text on failed
|
||||
// install shouldn't change to "Install" text because it was clicked and went
|
||||
// pending. Once the status is no longer pending, like 'installed' the
|
||||
// text will update to "Reinstall")
|
||||
if (status !== "pending_install" && status !== "pending_uninstall") {
|
||||
setDisplayActionItems({
|
||||
install: {
|
||||
text: getInstallButtonText(status),
|
||||
icon: getInstallButtonIcon(status),
|
||||
},
|
||||
uninstall: {
|
||||
text: getUninstallButtonText(status),
|
||||
icon: getUninstallButtonIcon(status),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const installButtonText = getInstallButtonText(status);
|
||||
const installButtonIcon = getInstallButtonIcon(status);
|
||||
|
||||
// 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_install" ? null : last_install;
|
||||
const isAppStoreApp = !!app_store_app;
|
||||
const canUninstallSoftware = !isAppStoreApp && !!software_package;
|
||||
|
||||
const isMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
|
|
@ -203,45 +286,60 @@ const InstallerStatusAction = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
setLocalStatus("pending_install");
|
||||
const onClickInstallAction = useCallback(async () => {
|
||||
try {
|
||||
await deviceApi.installSelfServiceSoftware(deviceToken, id);
|
||||
if (isMountedRef.current) {
|
||||
onInstall();
|
||||
onInstallOrUninstall();
|
||||
}
|
||||
} catch (error) {
|
||||
renderFlash("error", "Couldn't install. Please try again.");
|
||||
if (isMountedRef.current) {
|
||||
setLocalStatus("failed_install");
|
||||
}
|
||||
}
|
||||
}, [deviceToken, id, onInstall, renderFlash]);
|
||||
}, [deviceToken, id, onInstallOrUninstall, renderFlash]);
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__item-status-action`}>
|
||||
<div className={`${baseClass}__item-actions`}>
|
||||
<div className={`${baseClass}__item-action`}>
|
||||
{installButtonText ? (
|
||||
<Button
|
||||
variant="text-icon"
|
||||
type="button"
|
||||
className={`${baseClass}__item-action-button`}
|
||||
onClick={onClickInstallAction}
|
||||
disabled={
|
||||
status === "pending_install" || status === "pending_uninstall"
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={displayActionItems.install.icon}
|
||||
color="core-fleet-blue"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<span data-testid={`${baseClass}__install-button--test`}>
|
||||
{displayActionItems.install.text}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`${baseClass}__item-action`}>
|
||||
{canUninstallSoftware && (
|
||||
<Button
|
||||
variant="text-icon"
|
||||
type="button"
|
||||
className={`${baseClass}__item-action-button`}
|
||||
onClick={onClick}
|
||||
disabled={localStatus === "pending_install"}
|
||||
onClick={onClickUninstallAction}
|
||||
disabled={
|
||||
status === "pending_install" || status === "pending_uninstall"
|
||||
}
|
||||
>
|
||||
{installButtonIcon && (
|
||||
<Icon
|
||||
name={installButtonIcon}
|
||||
color="core-fleet-blue"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
<span data-testid={`${baseClass}__action-button--test`}>
|
||||
{installButtonText}
|
||||
<Icon
|
||||
name={displayActionItems.uninstall.icon}
|
||||
color="core-fleet-blue"
|
||||
size="small"
|
||||
/>
|
||||
<span data-testid={`${baseClass}__uninstall-button--test`}>
|
||||
{displayActionItems.uninstall.text}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
DEFAULT_EMPTY_CELL_VALUE
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -258,6 +356,7 @@ interface ISelfServiceTableHeaders {
|
|||
deviceToken: string;
|
||||
onInstall: () => void;
|
||||
onShowInstallerDetails: (uuid?: InstallOrCommandUuid) => void;
|
||||
onClickUninstallAction: (software: IHostSoftware) => void;
|
||||
}
|
||||
|
||||
// NOTE: cellProps come from react-table
|
||||
|
|
@ -266,6 +365,7 @@ export const generateSoftwareTableHeaders = ({
|
|||
deviceToken,
|
||||
onInstall,
|
||||
onShowInstallerDetails,
|
||||
onClickUninstallAction,
|
||||
}: ISelfServiceTableHeaders): ISoftwareTableConfig[] => {
|
||||
const tableHeaders: ISoftwareTableConfig[] = [
|
||||
{
|
||||
|
|
@ -320,7 +420,10 @@ export const generateSoftwareTableHeaders = ({
|
|||
<InstallerStatusAction
|
||||
deviceToken={deviceToken}
|
||||
software={cellProps.row.original}
|
||||
onInstall={onInstall}
|
||||
onInstallOrUninstall={onInstall}
|
||||
onClickUninstallAction={() =>
|
||||
onClickUninstallAction(cellProps.row.original)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { noop } from "lodash";
|
||||
import UninstallSoftwareModal from "./UninstallSoftwareModal";
|
||||
|
||||
describe("UninstallSoftwareModal", () => {
|
||||
it("renders the generic uninstall message with software name", () => {
|
||||
render(
|
||||
<UninstallSoftwareModal
|
||||
softwareId={1}
|
||||
softwareName="Slack"
|
||||
token="abc"
|
||||
onExit={noop}
|
||||
onSuccess={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Uninstalling this software will remove it and may remove Slack data from your device/i
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: /Uninstall/i })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: /Cancel/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders the generic uninstall message with default software name", () => {
|
||||
render(
|
||||
<UninstallSoftwareModal
|
||||
softwareId={1}
|
||||
token="abc"
|
||||
onExit={noop}
|
||||
onSuccess={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Uninstalling this software will remove it/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders the MSI-specific message when installer type is 'msi'", () => {
|
||||
render(
|
||||
<UninstallSoftwareModal
|
||||
softwareId={1}
|
||||
softwareName="Zoom"
|
||||
softwareInstallerType="msi"
|
||||
version="5.0.0"
|
||||
token="abc"
|
||||
onExit={noop}
|
||||
onSuccess={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/this will only uninstall version 5.0.0/i)
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: /click here/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not render the MSI-specific message for non-msi installer types", () => {
|
||||
render(
|
||||
<UninstallSoftwareModal
|
||||
softwareId={1}
|
||||
softwareName="Zoom"
|
||||
softwareInstallerType="pkg"
|
||||
version="5.0.0"
|
||||
token="abc"
|
||||
onExit={noop}
|
||||
onSuccess={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/this will only uninstall version/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import React, { useCallback, useContext, useState } from "react";
|
||||
|
||||
import deviceUserAPI from "services/entities/device_user";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
|
||||
const baseClass = "uninstall-software-modal";
|
||||
|
||||
interface IUninstallSoftwareModalProps {
|
||||
softwareId: number;
|
||||
softwareName?: string;
|
||||
softwareInstallerType?: string;
|
||||
version?: string;
|
||||
token: string;
|
||||
onExit: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UninstallSoftwareModal = ({
|
||||
softwareId,
|
||||
softwareName,
|
||||
softwareInstallerType,
|
||||
version,
|
||||
token,
|
||||
onExit,
|
||||
onSuccess,
|
||||
}: IUninstallSoftwareModalProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isUninstalling, setIsUninstalling] = useState(false);
|
||||
|
||||
const onUninstallSoftware = useCallback(async () => {
|
||||
setIsUninstalling(true);
|
||||
try {
|
||||
await deviceUserAPI.uninstallSelfServiceSoftware(token, softwareId);
|
||||
// TODO: Change this toast message or hide it all together?
|
||||
// renderFlash("success", "Software uninstalled successfully!");
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
renderFlash("error", "Couldn't uninstall. Please try again.");
|
||||
}
|
||||
setIsUninstalling(false);
|
||||
onExit();
|
||||
}, [softwareId, renderFlash, onSuccess, onExit]);
|
||||
|
||||
const displaySoftwareName = softwareName || "software";
|
||||
const msiInstaller = softwareInstallerType === "msi";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={baseClass}
|
||||
title={`Uninstall ${displaySoftwareName}`}
|
||||
onExit={onExit}
|
||||
isContentDisabled={isUninstalling}
|
||||
>
|
||||
<>
|
||||
<p>
|
||||
Uninstalling this software will remove it and may remove{" "}
|
||||
{softwareName} data from your device. You can always reinstall it
|
||||
again later.
|
||||
</p>
|
||||
{msiInstaller && (
|
||||
<p>
|
||||
By default, this will only uninstall version {version}. To learn how
|
||||
to uninstall other versions,{" "}
|
||||
<CustomLink
|
||||
url={`${LEARN_MORE_ABOUT_BASE_LINK}/uninstalling-windows-software`}
|
||||
text="click here"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
variant="alert"
|
||||
onClick={onUninstallSoftware}
|
||||
isLoading={isUninstalling}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
<Button variant="inverse-alert" onClick={onExit}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UninstallSoftwareModal;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.uninstall-software-modal {
|
||||
overflow-wrap: anywhere; // Prevent long software name overflow
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./UninstallSoftwareModal";
|
||||
|
|
@ -33,9 +33,23 @@
|
|||
gap: $pad-small;
|
||||
}
|
||||
|
||||
.self-service-table__status-content {
|
||||
min-width: 100px; // No table layout shift when changed to pending status
|
||||
}
|
||||
|
||||
.self-service-table__item-status-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.self-service-table__item-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
.self-service-table__item-action {
|
||||
min-width: 82px; // Second action buttons align between rows
|
||||
}
|
||||
}
|
||||
|
||||
.categories-menu {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,17 @@ export default {
|
|||
return sendRequest("POST", path);
|
||||
},
|
||||
|
||||
uninstallSelfServiceSoftware: (
|
||||
deviceToken: string,
|
||||
softwareTitleId: number
|
||||
) => {
|
||||
const { DEVICE_SOFTWARE_UNINSTALL } = endpoints;
|
||||
return sendRequest(
|
||||
"POST",
|
||||
DEVICE_SOFTWARE_UNINSTALL(deviceToken, softwareTitleId)
|
||||
);
|
||||
},
|
||||
|
||||
/** Gets more info on FMA/custom package install for device user */
|
||||
getSoftwareInstallResult: (deviceToken: string, uuid: string) => {
|
||||
const { DEVICE_SOFTWARE_INSTALL_RESULTS } = endpoints;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export default {
|
|||
`/${API_VERSION}/fleet/device/${token}/software/install/${softwareTitleId}`,
|
||||
DEVICE_SOFTWARE_INSTALL_RESULTS: (token: string, uuid: string) =>
|
||||
`/${API_VERSION}/fleet/device/${token}/software/install/${uuid}/results`,
|
||||
DEVICE_SOFTWARE_UNINSTALL: (token: string, softwareTitleId: number) =>
|
||||
`/${API_VERSION}/fleet/device/${token}/software/uninstall/${softwareTitleId}`,
|
||||
DEVICE_VPP_COMMAND_RESULTS: (token: string, uuid: string) =>
|
||||
`/${API_VERSION}/fleet/device/${token}/software/commands/${uuid}/results`,
|
||||
DEVICE_USER_MDM_ENROLLMENT_PROFILE: (token: string): string => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue