Files
fleet/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostSoftwareLibrary.tsx
T
jacobshandling d87f69563f Fix and improve handling of "installed" software scenarios (#36516)
**Related issue:** Resolves #31973 

Addresses a few closely related issues around determining the
appropriate UI to display for installed software

- Pass the inherited host's `softwareUpdatedAt` to the handler
responsible for determining the software's "UI display status" so it can
calculate whether it is "recently installed", as intended.
- In the scenario where the above is _not_ passed in for some reason,
default to "installed" ui display status any time the software's status
is "installed"
- Add a check that ensures the above default "installed" ui status is
captured even when `installed_versions` is `null`. This scenario
previously returned an "uninstalled" ui display status, which caused
this bug initially

<img width="1720" height="880" alt="Screenshot 2025-12-01 at 12 02
29 PM"
src="https://github.com/user-attachments/assets/20874ff5-133c-4b74-8634-2cdc2d3a1497"
/>

- [x] Changes file added for user-visible changes in `changes/
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
2025-12-01 13:17:42 -08:00

678 lines
21 KiB
TypeScript

import React, {
useCallback,
useContext,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { AxiosError } from "axios";
import hostAPI, {
IGetHostSoftwareResponse,
IHostSoftwareQueryKey,
} from "services/entities/hosts";
import PATHS from "router/paths";
import {
IHostSoftware,
IVPPHostSoftware,
ISoftware,
} from "interfaces/software";
import { HostPlatform, isIPadOrIPhone, isAndroid } from "interfaces/platform";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { getPathWithQueryParams } from "utilities/url";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import CardHeader from "components/CardHeader";
import DataError from "components/DataError";
import Spinner from "components/Spinner";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import SoftwareInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal";
import SoftwareIpaInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal";
import SoftwareScriptDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal";
import VppInstallDetailsModal from "components/ActivityDetails/InstallDetails/VppInstallDetailsModal";
import SoftwareUninstallDetailsModal, {
ISWUninstallDetailsParentState,
} from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal";
import { generateHostSWLibraryTableHeaders } from "./HostSoftwareLibraryTable/HostSoftwareLibraryTableConfig";
import HostSoftwareLibraryTable from "./HostSoftwareLibraryTable";
import { getInstallErrorMessage, getUninstallErrorMessage } from "./helpers";
import { getUiStatus } from "../Software/helpers";
import SoftwareUpdateModal from "../Software/SelfService/components/SoftwareUpdateModal";
const baseClass = "host-software-library-card";
export interface ITableSoftware extends Omit<ISoftware, "vulnerabilities"> {
vulnerabilities: string[]; // for client-side search purposes, we only want an array of cve strings
}
interface IHostSoftwareLibraryProps {
/** This is the host id or the device token */
id: number | string;
platform: HostPlatform;
hostDisplayName: string;
softwareUpdatedAt?: string;
router: InjectedRouter;
queryParams: ReturnType<typeof parseHostSoftwareLibraryQueryParams>;
pathname: string;
hostTeamId: number;
hostName: string;
onShowInventoryVersions: (software?: IHostSoftware) => void;
isSoftwareEnabled?: boolean;
hostScriptsEnabled?: boolean;
hostMDMEnrolled?: boolean;
isHostOnline?: boolean;
refetchHostDetails: () => void;
isHostDetailsPolling: boolean;
}
const DEFAULT_SEARCH_QUERY = "";
const DEFAULT_SORT_DIRECTION = "asc";
const DEFAULT_SORT_HEADER = "name";
const DEFAULT_PAGE = 0;
const DEFAULT_PAGE_SIZE = 20;
export const parseHostSoftwareLibraryQueryParams = (queryParams: {
page?: string;
query?: string;
order_key?: string;
order_direction?: "asc" | "desc";
self_service?: string;
}) => {
const searchQuery = queryParams?.query ?? DEFAULT_SEARCH_QUERY;
const sortHeader = queryParams?.order_key ?? DEFAULT_SORT_HEADER;
const sortDirection = queryParams?.order_direction ?? DEFAULT_SORT_DIRECTION;
const page = queryParams?.page
? parseInt(queryParams.page, 10)
: DEFAULT_PAGE;
const pageSize = DEFAULT_PAGE_SIZE;
const selfService = queryParams?.self_service === "true";
return {
page,
query: searchQuery,
order_key: sortHeader,
order_direction: sortDirection,
per_page: pageSize,
available_for_install: true, // always true for host installers
self_service: selfService,
};
};
const HostSoftwareLibrary = ({
id,
platform,
hostDisplayName,
softwareUpdatedAt,
hostScriptsEnabled,
router,
queryParams,
pathname,
hostTeamId = 0,
hostName,
onShowInventoryVersions,
isSoftwareEnabled = false,
hostMDMEnrolled,
isHostOnline = false,
refetchHostDetails,
isHostDetailsPolling,
}: IHostSoftwareLibraryProps) => {
const { renderFlash } = useContext(NotificationContext);
const {
isGlobalAdmin,
isGlobalMaintainer,
isTeamAdmin,
isTeamMaintainer,
} = useContext(AppContext);
const isUnsupported = isAndroid(platform); // no Android software
const isWindowsHost = platform === "windows";
const isIPadOrIPhoneHost = isIPadOrIPhone(platform);
const isAndroidHost = isAndroid(platform);
const isMacOSHost = platform === "darwin";
const [hostSoftwareLibraryRes, setHostSoftwareLibraryRes] = useState<
IGetHostSoftwareResponse | undefined
>(undefined);
const [
selectedSoftwareUpdates,
setSelectedSoftwareUpdates,
] = useState<IHostSoftware | null>(null);
// these states and modal logic exist at this level instead of the page level to match the similar
// pattern on
// the device user page, which facilitates manipulating relevant UI states e.g.
// "updating..." when the user clicks "Retry" in the SoftwareInstallDetailsModal
const [
selectedHostSWInstallDetails,
setSelectedHostSWInstallDetails,
] = useState<IHostSoftware | null>(null);
const [
selectedHostSWIpaInstallDetails,
setSelectedHostSWIpaInstallDetails,
] = useState<IHostSoftware | null>(null);
const [
selectedHostSWScriptDetails,
setSelectedHostSWScriptDetails,
] = useState<IHostSoftware | null>(null);
const [
selectedHostSWUninstallDetails,
setSelectedHostSWUninstallDetails,
] = useState<ISWUninstallDetailsParentState | null>(null);
const [
selectedVPPInstallDetails,
setSelectedVPPInstallDetails,
] = useState<IVPPHostSoftware | null>(null);
const enhancedSoftware = useMemo(() => {
if (!hostSoftwareLibraryRes) return [];
return hostSoftwareLibraryRes.software.map((software) => ({
...software,
ui_status: getUiStatus(software, isHostOnline, softwareUpdatedAt),
}));
}, [hostSoftwareLibraryRes, isHostOnline, softwareUpdatedAt]);
const pendingSoftwareSetRef = useRef<Set<string>>(new Set()); // Track for polling
const pollingTimeoutIdRef = useRef<NodeJS.Timeout | null>(null);
const isAwaitingHostDetailsPolling = useRef(isHostDetailsPolling);
const queryKey = useMemo<IHostSoftwareQueryKey[]>(() => {
return [
{
scope: "host_software",
id: id as number,
softwareUpdatedAt,
...queryParams,
},
];
}, [queryParams, id, softwareUpdatedAt]);
const {
isLoading: hostSoftwareLibraryLoading,
isError: hostSoftwareLibraryError,
isFetching: hostSoftwareLibraryFetching,
refetch: refetchHostSoftwareLibrary,
} = useQuery<
IGetHostSoftwareResponse,
AxiosError,
IGetHostSoftwareResponse,
IHostSoftwareQueryKey[]
>(queryKey, () => hostAPI.getHostSoftware(queryKey[0]), {
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isSoftwareEnabled && !isUnsupported,
keepPreviousData: true,
onSuccess: (response) => {
setHostSoftwareLibraryRes(response);
},
});
// After host details polling (in parent) finishes, refetch software data.
// Ensures self service data reflects updates to installed_versions from the latest host details.
useEffect(() => {
// Detect completion of the host details polling (in parent)
// Once host details polling completes, refetch software data to retreive updated installed_versions keyed from host details data
if (isAwaitingHostDetailsPolling.current && !isHostDetailsPolling) {
refetchHostSoftwareLibrary();
}
isAwaitingHostDetailsPolling.current = isHostDetailsPolling;
}, [isHostDetailsPolling, refetchHostSoftwareLibrary]);
// Poll for pending installs/uninstalls
const { refetch: refetchForPendingInstallsOrUninstalls } = useQuery<
IGetHostSoftwareResponse,
AxiosError
>(
["pending_installs", queryKey[0]],
() => hostAPI.getHostSoftware(queryKey[0]),
{
enabled: false,
onSuccess: (response) => {
// Get the set of pending software IDs
const newPendingSet = new Set(
response.software
.filter(
(software) =>
software.status === "pending_install" ||
software.status === "pending_uninstall"
)
.map((software) => String(software.id))
);
// Refresh host details if the number of pending installs or uninstalls has decreased
// To update the software library information of the newly installed/uninstalled software
if (newPendingSet.size < pendingSoftwareSetRef.current.size) {
refetchHostDetails();
}
// Compare new set with the previous set
const setsAreEqual =
newPendingSet.size === pendingSoftwareSetRef.current.size &&
[...newPendingSet].every((pendingId) =>
pendingSoftwareSetRef.current.has(pendingId)
);
if (newPendingSet.size > 0) {
// If the set changed, update and continue polling
if (!setsAreEqual) {
pendingSoftwareSetRef.current = newPendingSet;
setHostSoftwareLibraryRes(response);
}
// Continue polling
if (pollingTimeoutIdRef.current) {
clearTimeout(pollingTimeoutIdRef.current);
}
pollingTimeoutIdRef.current = setTimeout(() => {
refetchForPendingInstallsOrUninstalls();
}, 5000);
} else {
// No pending installs nor pending uninstalls, stop polling and refresh data
pendingSoftwareSetRef.current = new Set();
if (pollingTimeoutIdRef.current) {
clearTimeout(pollingTimeoutIdRef.current);
pollingTimeoutIdRef.current = null;
}
setHostSoftwareLibraryRes(response);
}
},
onError: () => {
pendingSoftwareSetRef.current = new Set();
renderFlash(
"error",
"We're having trouble checking pending installs. Please refresh the page."
);
},
}
);
// Stop polling if the host goes offline
// Polling will automatically resume when host is online with pending installs
useEffect(() => {
if (!isHostOnline) {
if (pollingTimeoutIdRef.current) {
clearTimeout(pollingTimeoutIdRef.current);
pollingTimeoutIdRef.current = null;
}
pendingSoftwareSetRef.current = new Set();
}
}, [isHostOnline]);
const startPollingForPendingInstallsOrUninstalls = useCallback(
(pendingIds: string[]) => {
if (isHostOnline) {
const newSet = new Set(pendingIds);
const setsAreEqual =
newSet.size === pendingSoftwareSetRef.current.size &&
[...newSet].every((pendingId) =>
pendingSoftwareSetRef.current.has(pendingId)
);
if (!setsAreEqual) {
pendingSoftwareSetRef.current = newSet;
// Clear any existing timeout to avoid overlap
if (pollingTimeoutIdRef.current) {
clearTimeout(pollingTimeoutIdRef.current);
}
// Starts polling for pending installs/uninstalls
refetchForPendingInstallsOrUninstalls();
}
}
},
[refetchForPendingInstallsOrUninstalls, isHostOnline]
);
// Cleanup on unmount
useEffect(() => {
return () => {
pendingSoftwareSetRef.current = new Set();
if (pollingTimeoutIdRef.current) {
clearTimeout(pollingTimeoutIdRef.current);
pollingTimeoutIdRef.current = null;
}
};
}, []);
// On initial load or data change, check for pending installs/uninstalls
useEffect(() => {
const pendingSoftware = hostSoftwareLibraryRes?.software.filter(
(software) =>
software.status === "pending_install" ||
software.status === "pending_uninstall"
);
const pendingIds = pendingSoftware?.map((s) => String(s.id)) ?? [];
if (pendingIds.length > 0) {
startPollingForPendingInstallsOrUninstalls(pendingIds);
}
}, [hostSoftwareLibraryRes, startPollingForPendingInstallsOrUninstalls]);
const onAddSoftware = useCallback(() => {
// "Add Software" path dependent on host's platform
const addSoftwarePathForHostPlatform = () => {
if (isIPadOrIPhoneHost || isAndroidHost) {
return getPathWithQueryParams(PATHS.SOFTWARE_ADD_APP_STORE, {
platform: isAndroidHost ? "android" : "apple",
});
}
if (isMacOSHost || isWindowsHost) {
return PATHS.SOFTWARE_ADD_FLEET_MAINTAINED;
}
return PATHS.SOFTWARE_ADD_PACKAGE;
};
router.push(
getPathWithQueryParams(addSoftwarePathForHostPlatform(), {
team_id: hostTeamId,
})
);
}, [
hostTeamId,
isIPadOrIPhoneHost,
isAndroidHost,
isMacOSHost,
isWindowsHost,
router,
]);
const onShowUpdateDetails = useCallback(
(software?: IHostSoftware) => {
if (software) {
setSelectedSoftwareUpdates(software);
}
},
[setSelectedSoftwareUpdates]
);
const onSetSelectedHostSWInstallDetails = useCallback(
(hostSW?: IHostSoftware) => {
if (hostSW) {
setSelectedHostSWInstallDetails(hostSW);
}
},
[setSelectedHostSWInstallDetails]
);
const onSetSelectedHostSWIpaInstallDetails = useCallback(
(hostSW?: IHostSoftware) => {
if (hostSW) {
setSelectedHostSWIpaInstallDetails(hostSW);
}
},
[setSelectedHostSWIpaInstallDetails]
);
const onSetSelectedHostSWScriptDetails = useCallback(
(hostSW?: IHostSoftware) => {
if (hostSW) {
setSelectedHostSWScriptDetails(hostSW);
}
},
[setSelectedHostSWScriptDetails]
);
const onSetSelectedHostSWUninstallDetails = useCallback(
(uninstallDetails?: ISWUninstallDetailsParentState) => {
if (uninstallDetails) {
setSelectedHostSWUninstallDetails(uninstallDetails);
}
},
[setSelectedHostSWUninstallDetails]
);
const onSetSelectedVPPInstallDetails = useCallback(
(s?: IVPPHostSoftware) => {
if (s) {
setSelectedVPPInstallDetails(s);
}
},
[setSelectedVPPInstallDetails]
);
const onInstallOrUninstall = useCallback(() => {
// For online hosts, poll for change in pending statuses
// For offline hosts, refresh the data without polling
isHostOnline
? refetchForPendingInstallsOrUninstalls()
: refetchHostSoftwareLibrary();
}, [
refetchForPendingInstallsOrUninstalls,
refetchHostSoftwareLibrary,
isHostOnline,
]);
const hasSWWriteRole = Boolean(
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer
);
// 4.77 Currently Android apps can only be installed via self-service by end user
const userHasSWWritePermission = hasSWWriteRole && !isAndroidHost;
const isMountedRef = useRef(false);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const onClickInstallAction = useCallback(
async (softwareId: number, isScriptPackage = false) => {
try {
await hostAPI.installHostSoftwarePackage(id as number, softwareId);
if (isMountedRef.current) {
onInstallOrUninstall();
}
const message = () => {
switch (true) {
case isHostOnline && isScriptPackage:
return "Script is running.";
case isHostOnline && !isScriptPackage:
return "Software is installing.";
case !isHostOnline && isScriptPackage:
return "Script will run when the host comes online.";
default:
return "Software will install when the host comes online.";
}
};
renderFlash(
"success",
<>
{message()} To see details, go to <b>Details &gt; Activity</b>.
</>
);
} catch (e) {
renderFlash("error", getInstallErrorMessage(e));
}
},
[id, renderFlash, onInstallOrUninstall, isHostOnline]
);
const onClickUninstallAction = useCallback(
async (softwareId: number) => {
try {
await hostAPI.uninstallHostSoftwarePackage(id as number, softwareId);
if (isMountedRef.current) {
onInstallOrUninstall();
}
renderFlash(
"success",
<>
Software{" "}
{isHostOnline
? "is uninstalling"
: "will uninstall when the host comes online"}
. To see details, go to <b>Details &gt; Activity</b>.
</>
);
} catch (e) {
renderFlash("error", getUninstallErrorMessage(e));
}
},
[id, renderFlash, onInstallOrUninstall, isHostOnline]
);
const tableConfig = useMemo(() => {
return generateHostSWLibraryTableHeaders({
userHasSWWritePermission,
hostScriptsEnabled,
hostMDMEnrolled,
router,
teamId: hostTeamId,
hostName,
baseClass,
onShowInventoryVersions,
onShowUpdateDetails,
onSetSelectedHostSWInstallDetails,
onSetSelectedHostSWIpaInstallDetails,
onSetSelectedHostSWScriptDetails,
onSetSelectedHostSWUninstallDetails,
onSetSelectedVPPInstallDetails,
onClickInstallAction,
onClickUninstallAction,
isHostOnline,
});
}, [
userHasSWWritePermission,
hostScriptsEnabled,
hostMDMEnrolled,
router,
hostTeamId,
hostName,
onShowInventoryVersions,
onShowUpdateDetails,
onSetSelectedHostSWInstallDetails,
onSetSelectedHostSWIpaInstallDetails,
onSetSelectedHostSWScriptDetails,
onSetSelectedHostSWUninstallDetails,
onSetSelectedVPPInstallDetails,
onClickInstallAction,
onClickUninstallAction,
isHostOnline,
]);
const isLoading = hostSoftwareLibraryLoading;
const isError = hostSoftwareLibraryError;
const data = hostSoftwareLibraryRes;
const enhancedData = enhancedSoftware;
const renderHostSoftware = () => {
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <DataError verticalPaddingSize="pad-xxxlarge" />;
}
return (
<HostSoftwareLibraryTable
isLoading={hostSoftwareLibraryFetching}
data={data}
enhancedData={enhancedData}
platform={platform}
router={router}
tableConfig={tableConfig}
sortHeader={queryParams.order_key}
sortDirection={queryParams.order_direction}
searchQuery={queryParams.query}
page={queryParams.page}
pagePath={pathname}
selfService={queryParams.self_service}
/>
);
};
return (
<div className={baseClass}>
<div className={`${baseClass}__header`}>
<CardHeader subheader="Software available to be installed on this host" />
{userHasSWWritePermission && (
<Button variant="inverse" onClick={onAddSoftware}>
<Icon name="plus" />
<span>Add software</span>
</Button>
)}
</div>
{renderHostSoftware()}
{selectedSoftwareUpdates && (
<SoftwareUpdateModal
hostDisplayName={hostDisplayName}
software={selectedSoftwareUpdates}
onUpdate={onClickInstallAction}
onExit={() => setSelectedSoftwareUpdates(null)}
/>
)}
{selectedHostSWInstallDetails && (
<SoftwareInstallDetailsModal
details={{
host_display_name: hostDisplayName,
install_uuid:
selectedHostSWInstallDetails.software_package?.last_install
?.install_uuid, // slightly redundant, see explanation in `SoftwareInstallDetailsModal
}}
hostSoftware={selectedHostSWInstallDetails}
onCancel={() => setSelectedHostSWInstallDetails(null)}
/>
)}
{selectedHostSWIpaInstallDetails && (
<SoftwareIpaInstallDetailsModal
details={{
hostDisplayName,
fleetInstallStatus: selectedHostSWIpaInstallDetails.status,
appName:
selectedHostSWIpaInstallDetails.display_name ||
selectedHostSWIpaInstallDetails.name,
commandUuid:
selectedHostSWIpaInstallDetails.software_package?.last_install
?.install_uuid, // slightly redundant, see explanation in `SoftwareInstallDetailsModal
}}
hostSoftware={selectedHostSWIpaInstallDetails}
onCancel={() => setSelectedHostSWIpaInstallDetails(null)}
/>
)}
{selectedHostSWScriptDetails && (
<SoftwareScriptDetailsModal
details={{
host_display_name: hostDisplayName,
install_uuid:
selectedHostSWScriptDetails.software_package?.last_install
?.install_uuid,
}}
hostSoftware={selectedHostSWScriptDetails}
onCancel={() => setSelectedHostSWScriptDetails(null)}
/>
)}
{selectedHostSWUninstallDetails && (
<SoftwareUninstallDetailsModal
{...selectedHostSWUninstallDetails}
hostDisplayName={hostDisplayName}
onCancel={() => setSelectedHostSWUninstallDetails(null)}
/>
)}
{selectedVPPInstallDetails && (
<VppInstallDetailsModal
details={{
fleetInstallStatus: selectedVPPInstallDetails.status,
hostDisplayName,
appName:
selectedVPPInstallDetails.display_name ||
selectedVPPInstallDetails.name,
commandUuid: selectedVPPInstallDetails.commandUuid,
}}
hostSoftware={selectedVPPInstallDetails}
onCancel={() => setSelectedVPPInstallDetails(null)}
/>
)}
</div>
);
};
export default React.memo(HostSoftwareLibrary);