diff --git a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx index 0831c50e5b..8cb56bb83b 100644 --- a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx @@ -1,10 +1,12 @@ // Used on: Dashboard > activity, Host details > past activity +// Also used on Self-service failed install details import React from "react"; import { useQuery } from "react-query"; import { SoftwareInstallStatus } from "interfaces/software"; import mdmApi from "services/entities/mdm"; +import deviceUserAPI from "services/entities/device_user"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; @@ -30,13 +32,16 @@ export type IAppInstallDetails = Pick< | "software_title" | "app_store_id" | "status" ->; +> & { + deviceAuthToken?: string; +}; export const AppInstallDetails = ({ status, command_uuid = "", host_display_name = "", software_title = "", + deviceAuthToken, }: IAppInstallDetails) => { const { data: result, isLoading, isError } = useQuery< IMdmCommandResult, @@ -44,20 +49,22 @@ export const AppInstallDetails = ({ >( ["mdm_command_results", command_uuid], async () => { - return mdmApi.getCommandResults(command_uuid).then((response) => { - const results = response.results?.[0]; - if (!results) { - // FIXME: It's currently possible that the command results API response is empty for pending - // commands. As a temporary workaround to handle this case, we'll ignore the empty response and - // display some minimal pending UI. This should be removed once the API response is fixed. - return {} as IMdmCommandResult; - } - return { - ...results, - payload: atob(results.payload), - result: atob(results.result), - }; - }); + return deviceAuthToken + ? deviceUserAPI.getVppCommandResult(deviceAuthToken, command_uuid) + : mdmApi.getCommandResults(command_uuid).then((response) => { + const results = response.results?.[0]; + if (!results) { + // FIXME: It's currently possible that the command results API response is empty for pending + // commands. As a temporary workaround to handle this case, we'll ignore the empty response and + // display some minimal pending UI. This should be removed once the API response is fixed. + return {} as IMdmCommandResult; + } + return { + ...results, + payload: atob(results.payload), + result: atob(results.result), + }; + }); }, { refetchOnWindowFocus: false, @@ -136,9 +143,11 @@ export const AppInstallDetails = ({ export const AppInstallDetailsModal = ({ details, onCancel, + deviceAuthToken, }: { details: IAppInstallDetails; onCancel: () => void; + deviceAuthToken?: string; }) => { return ( <>
- +
diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx index 89c5c4add5..693bd89f06 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx @@ -1,4 +1,5 @@ // Used on: Dashboard > activity, Host details > past activity +// Also used on Self-service failed install details import React from "react"; import { useQuery } from "react-query"; @@ -11,6 +12,7 @@ import { ISoftwareInstallResults, } from "interfaces/software"; import softwareAPI from "services/entities/software"; +import deviceUserAPI from "services/entities/device_user"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; @@ -30,7 +32,9 @@ const baseClass = "software-install-details"; export type IPackageInstallDetails = Pick< IActivityDetails, "install_uuid" | "host_display_name" ->; +> & { + deviceAuthToken?: string; +}; const StatusMessage = ({ result: { @@ -50,6 +54,8 @@ const StatusMessage = ({ "the host" ); + // TODO: Potential implementation HumanTimeDiffWithDateTip for consistency + // Design currently looks weird since displayTimeStamp might split to multiple lines const timeStamp = updated_at || created_at; const displayTimeStamp = ["failed_install", "installed"].includes( status || "" @@ -92,6 +98,7 @@ const Output = ({ export const SoftwareInstallDetails = ({ host_display_name = "", install_uuid = "", + deviceAuthToken, }: IPackageInstallDetails) => { const { data: result, isLoading, isError, error } = useQuery< ISoftwareInstallResults, @@ -100,7 +107,9 @@ export const SoftwareInstallDetails = ({ >( ["softwareInstallResults", install_uuid], () => { - return softwareAPI.getSoftwareInstallResult(install_uuid); + return deviceAuthToken + ? deviceUserAPI.getSoftwareInstallResult(deviceAuthToken, install_uuid) + : softwareAPI.getSoftwareInstallResult(install_uuid); }, { refetchOnWindowFocus: false, @@ -151,9 +160,11 @@ export const SoftwareInstallDetails = ({ export const SoftwareInstallDetailsModal = ({ details, onCancel, + deviceAuthToken, }: { details: IPackageInstallDetails; onCancel: () => void; + deviceAuthToken?: string; }) => { return ( + Logo is displayed in the top bar and other +
+ areas of Fleet that have dark backgrounds. + + } />
+ URL is used in "Reach out to IT" links shown to + the end +
+ user (e.g. self-service and during MDM migration). + + } + > + Organization support URL + + } onChange={onInputChange} name="orgSupportURL" value={orgSupportURL} diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 0edc2aa01d..567a1a1080 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -36,6 +36,7 @@ import TabNav from "components/TabNav"; import TabText from "components/TabText"; import Icon from "components/Icon/Icon"; import FlashMessage from "components/FlashMessage"; +import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails"; import { normalizeEmptyValues } from "utilities/helpers"; import PATHS from "router/paths"; @@ -74,6 +75,8 @@ import { generateOtherEmailsValues, } from "../cards/User/helpers"; import HostHeader from "../cards/HostHeader/HostHeader"; +import { InstallOrCommandUuid } from "../cards/Software/SelfService/SelfServiceItem/SelfServiceItem"; +import { AppInstallDetailsModal } from "../../../../components/ActivityDetails/InstallDetails/AppInstallDetails"; const baseClass = "device-user"; @@ -81,8 +84,8 @@ const defaultCardClass = `${baseClass}__card`; const fullWidthCardClass = `${baseClass}__card--full-width`; const PREMIUM_TAB_PATHS = [ - PATHS.DEVICE_USER_DETAILS, PATHS.DEVICE_USER_DETAILS_SELF_SERVICE, + PATHS.DEVICE_USER_DETAILS, PATHS.DEVICE_USER_DETAILS_SOFTWARE, PATHS.DEVICE_USER_DETAILS_POLICIES, ] as const; @@ -130,6 +133,9 @@ const DeviceUserPage = ({ null ); const [showPolicyDetailsModal, setShowPolicyDetailsModal] = useState(false); + const [selectedSelfServiceUuid, setSelectedSelfServiceUuid] = useState< + InstallOrCommandUuid | undefined + >(undefined); const [showOSSettingsModal, setShowOSSettingsModal] = useState(false); const [showBootstrapPackageModal, setShowBootstrapPackageModal] = useState( false @@ -277,7 +283,7 @@ const DeviceUserPage = ({ } else { renderFlash( "error", - `We're having trouble fetching fresh vitals for this host. Please try again later.` + "We're having trouble fetching fresh vitals for this host. Please try again later." ); setShowRefetchSpinner(false); } @@ -329,6 +335,13 @@ const DeviceUserPage = ({ setShowOSSettingsModal(!showOSSettingsModal); }, [showOSSettingsModal, setShowOSSettingsModal]); + const onShowInstallerDetails = useCallback( + (uuid?: InstallOrCommandUuid) => { + setSelectedSelfServiceUuid(uuid); + }, + [setSelectedSelfServiceUuid] + ); + const onCancelPolicyDetailsModal = useCallback(() => { setShowPolicyDetailsModal(!showPolicyDetailsModal); setSelectedPolicy(null); @@ -402,8 +415,23 @@ const DeviceUserPage = ({ tabPaths = tabPaths.filter((path) => !path.includes("self-service")); } - const findSelectedTab = (pathname: string) => - findIndex(tabPaths, (x) => x.startsWith(pathname.split("?")[0])); + const findSelectedTab = (pathname: string) => { + const cleanPath = pathname.split("?")[0]; + // Filter tabPaths that are prefix of cleanPath + const matchingIndices = tabPaths + .map((tabPath, idx) => ({ tabPath, idx })) + .filter(({ tabPath }) => cleanPath.startsWith(tabPath)); + + if (matchingIndices.length === 0) { + return -1; + } + + // Return the index of the longest matching prefix + return matchingIndices.reduce((best, current) => + current.tabPath.length > best.tabPath.length ? current : best + ).idx; + }; + if (!isLoadingHost && host && findSelectedTab(location.pathname) === -1) { router.push(tabPaths[0]); } @@ -487,6 +515,7 @@ const DeviceUserPage = ({ pathname={location.pathname} queryParams={parseHostSoftwareQueryParams(location.query)} router={router} + onShowInstallerDetails={onShowInstallerDetails} /> )} @@ -616,6 +645,30 @@ const DeviceUserPage = ({ }} /> )} + {selectedSelfServiceUuid && + "install_uuid" in selectedSelfServiceUuid && + !!host && ( + setSelectedSelfServiceUuid(undefined)} + deviceAuthToken={deviceAuthToken} + /> + )} + {selectedSelfServiceUuid && + "command_uuid" in selectedSelfServiceUuid && + !!host && ( + setSelectedSelfServiceUuid(undefined)} + deviceAuthToken={deviceAuthToken} + /> + )} {selectedSoftwareDetails && !!host && ( ReactNode; }; @@ -38,15 +38,29 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< iconName: "success", displayText: "Installed", tooltip: ({ isAppStoreApp }) => - isAppStoreApp - ? "The host acknowledged the MDM command to install App Store app." - : "Software is installed (install script finished with exit code 0).", + isAppStoreApp ? ( + <> + The host acknowledged the MDM +
+ command to install the app. + + ) : ( + <> + Software is installed (install +
+ script finished with exit code 0). + + ), }, pending_install: { iconName: "pending-outline", displayText: "Installing (pending)", - tooltip: () => - "Fleet is installing or will install when the host comes online.", + tooltip: () => ( + <> + Fleet is installing or will install +
when the host comes online. + + ), }, pending_uninstall: { iconName: "pending-outline", @@ -93,7 +107,7 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< ) : ( <> {softwareName ? {softwareName} : "Software"} can be installed - on the host. Select Actions {">"} Install to install. + on the host. Select Actions > Install to install. ), }, 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 75da46cfb8..9d42c5fc59 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx @@ -1,6 +1,7 @@ import React from "react"; import { screen } from "@testing-library/react"; +import { noop } from "lodash"; import { createCustomRenderer, createMockRouter } from "test/test-utils"; import mockServer from "test/mock-server"; import { customDeviceSoftwareHandler } from "test/handlers/device-handler"; @@ -26,6 +27,7 @@ const TEST_PROPS: ISoftwareSelfServiceProps = { exploit: false, }, router: createMockRouter(), + onShowInstallerDetails: noop, }; describe("SelfService", () => { @@ -46,14 +48,12 @@ describe("SelfService", () => { render(); // waiting for the device software data to render - await screen.findByText("test1"); + await screen.findAllByText("test1"); - expect(true).toBe(true); - expect(screen.getByText("test1")).toBeInTheDocument(); - expect(screen.getByText("test2")).toBeInTheDocument(); - expect(screen.getByText("test3")).toBeInTheDocument(); + expect(screen.getAllByText("test1").length).toBeGreaterThan(0); + expect(screen.getAllByText("test2").length).toBeGreaterThan(0); + expect(screen.getAllByText("test3").length).toBeGreaterThan(0); expect(screen.getByText("3 items")).toBeInTheDocument(); - screen.debug(); }); it("should render the contact link text if contact url is provided", () => { @@ -103,11 +103,12 @@ describe("SelfService", () => { exploit: false, }} router={createMockRouter()} + onShowInstallerDetails={noop} /> ); // waiting for the device software data to render - await screen.findByText("test-software"); + await screen.findAllByText("test-software"); expect( screen.getByTestId("self-service-item__status--test") @@ -134,7 +135,7 @@ describe("SelfService", () => { render(); // waiting for the device software data to render - await screen.findByText("test-software"); + await screen.findAllByText("test-software"); expect( screen.getByTestId("self-service-item__status--test") @@ -161,7 +162,7 @@ describe("SelfService", () => { render(); // waiting for the device software data to render - await screen.findByText("test-software"); + await screen.findAllByText("test-software"); expect( screen.queryByTestId("self-service-item__status--test") @@ -188,11 +189,11 @@ describe("SelfService", () => { render(); // waiting for the device software data to render - await screen.findByText("test-software"); + await screen.findAllByText("test-software"); expect( screen.getByTestId("self-service-item__status--test") - ).toHaveTextContent("Pending"); + ).toHaveTextContent("Installing..."); expect( screen.queryByTestId("self-service-item__item-action-button--test") diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx index c9f85766b9..425bf3cc61 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -1,8 +1,17 @@ -import React, { useCallback } from "react"; +import React, { + useCallback, + useContext, + useMemo, + useEffect, + useRef, + useState, +} from "react"; import { useQuery } from "react-query"; import { InjectedRouter } from "react-router"; import { AxiosError } from "axios"; +import { NotificationContext } from "context/notification"; + import deviceApi, { IDeviceSoftwareQueryKey, IGetDeviceSoftwareResponse, @@ -10,6 +19,7 @@ import deviceApi, { import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import { pluralize } from "utilities/strings/stringUtils"; +import { getPathWithQueryParams } from "utilities/url"; import Card from "components/Card"; import CardHeader from "components/CardHeader"; @@ -17,20 +27,20 @@ import CustomLink from "components/CustomLink"; import DataError from "components/DataError"; import EmptyTable from "components/EmptyTable"; import Spinner from "components/Spinner"; - +import SearchField from "components/forms/fields/SearchField"; import Pagination from "components/Pagination"; import { parseHostSoftwareQueryParams } from "../HostSoftware"; import SelfServiceItem from "./SelfServiceItem"; +import { InstallOrCommandUuid } from "./SelfServiceItem/SelfServiceItem"; const baseClass = "software-self-service"; // These default params are not subject to change by the user const DEFAULT_SELF_SERVICE_QUERY_PARAMS = { - per_page: 9, + per_page: 24, // Divisible by 2, 3, 4 so pagination renders well on responsive widths order_key: "name", order_direction: "asc", - query: "", self_service: true, } as const; @@ -41,6 +51,7 @@ export interface ISoftwareSelfServiceProps { pathname: string; queryParams: ReturnType; router: InjectedRouter; + onShowInstallerDetails: (uuid?: InstallOrCommandUuid) => void; } const SoftwareSelfService = ({ @@ -50,42 +61,295 @@ const SoftwareSelfService = ({ pathname, queryParams, router, + onShowInstallerDetails, }: ISoftwareSelfServiceProps) => { - const { data, isLoading, isError, refetch } = useQuery< - IGetDeviceSoftwareResponse, - AxiosError, - IGetDeviceSoftwareResponse, - IDeviceSoftwareQueryKey[] - >( - [ + const { renderFlash } = useContext(NotificationContext); + + const [selfServiceData, setSelfServiceData] = useState< + IGetDeviceSoftwareResponse | undefined + >(undefined); + + const pendingSoftwareSetRef = useRef>(new Set()); // Track for polling + const pollingTimeoutIdRef = useRef(null); + + const queryKey = useMemo(() => { + return [ { scope: "device_software", id: deviceToken, page: queryParams.page, + query: queryParams.query, ...DEFAULT_SELF_SERVICE_QUERY_PARAMS, }, - ], - ({ queryKey }) => deviceApi.getDeviceSoftware(queryKey[0]), + ]; + }, [deviceToken, queryParams.page, queryParams.query]); + + // Fetch self-service software (regular API call) + const { isLoading, isError, isFetching } = useQuery< + IGetDeviceSoftwareResponse, + AxiosError, + IGetDeviceSoftwareResponse, + IDeviceSoftwareQueryKey[] + >(queryKey, (context) => deviceApi.getDeviceSoftware(context.queryKey[0]), { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: isSoftwareEnabled, + keepPreviousData: true, + staleTime: 7000, + onSuccess: (response) => { + setSelfServiceData(response); + }, + }); + + // Poll for pending installs + const { refetch: refetchForPendingInstalls } = useQuery< + IGetDeviceSoftwareResponse, + AxiosError + >( + ["pending_installs", queryKey[0]], + () => deviceApi.getDeviceSoftware(queryKey[0]), { - ...DEFAULT_USE_QUERY_OPTIONS, - enabled: isSoftwareEnabled, // if software inventory is disabled, we don't bother fetching and always show the empty state - keepPreviousData: true, - staleTime: 7000, + enabled: false, + onSuccess: (response) => { + // Get the set of pending software IDs + const newPendingSet = new Set( + response.software + .filter((software) => software.status === "pending_install") + .map((software) => String(software.id)) + ); + + // Compare new set with the previous set + const setsAreEqual = + newPendingSet.size === pendingSoftwareSetRef.current.size && + [...newPendingSet].every((id) => + pendingSoftwareSetRef.current.has(id) + ); + + if (newPendingSet.size > 0) { + // If the set changed, update and continue polling + if (!setsAreEqual) { + pendingSoftwareSetRef.current = newPendingSet; + setSelfServiceData(response); + } + + // Continue polling + if (pollingTimeoutIdRef.current) { + clearTimeout(pollingTimeoutIdRef.current); + } + pollingTimeoutIdRef.current = setTimeout(() => { + refetchForPendingInstalls(); + }, 5000); + } else { + // No pending installs, stop polling and refresh data + pendingSoftwareSetRef.current = new Set(); + if (pollingTimeoutIdRef.current) { + clearTimeout(pollingTimeoutIdRef.current); + pollingTimeoutIdRef.current = null; + } + setSelfServiceData(response); + } + }, + onError: () => { + pendingSoftwareSetRef.current = new Set(); + renderFlash( + "error", + "We're having trouble checking pending installs. Please refresh the page." + ); + }, } ); + const startPollingForPendingInstalls = useCallback( + (pendingIds: string[]) => { + const newSet = new Set(pendingIds); + const setsAreEqual = + newSet.size === pendingSoftwareSetRef.current.size && + [...newSet].every((id) => pendingSoftwareSetRef.current.has(id)); + if (!setsAreEqual) { + pendingSoftwareSetRef.current = newSet; + + // Clear any existing timeout to avoid overlap + if (pollingTimeoutIdRef.current) { + clearTimeout(pollingTimeoutIdRef.current); + } + refetchForPendingInstalls(); // Starts polling for pending installs + } + }, + [refetchForPendingInstalls] + ); + + // 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 + useEffect(() => { + const pendingSoftware = selfServiceData?.software.filter( + (software) => software.status === "pending_install" + ); + const pendingIds = pendingSoftware?.map((s) => String(s.id)) ?? []; + if (pendingIds.length > 0) { + startPollingForPendingInstalls(pendingIds); + } + }, [selfServiceData, startPollingForPendingInstalls]); + + const onInstall = useCallback(() => { + refetchForPendingInstalls(); + }, [refetchForPendingInstalls]); + + const onSearchQueryChange = (value: string) => { + router.push( + getPathWithQueryParams(pathname, { + query: value, + page: 0, // Always reset to page 0 when searching + }) + ); + }; + const onNextPage = useCallback(() => { - router.push(pathname.concat(`?page=${queryParams.page + 1}`)); - }, [pathname, queryParams.page, router]); + router.push( + getPathWithQueryParams(pathname, { + query: queryParams.query, + page: queryParams.page + 1, + }) + ); + }, [pathname, queryParams.page, queryParams.query, router]); const onPrevPage = useCallback(() => { - router.push(pathname.concat(`?page=${queryParams.page - 1}`)); - }, [pathname, queryParams.page, router]); + router.push( + getPathWithQueryParams(pathname, { + query: queryParams.query, + page: queryParams.page - 1, + }) + ); + }, [pathname, queryParams.page, queryParams.query, router]); // TODO: handle empty state better, this is just a placeholder for now // TODO: what should happen if query params are invalid (e.g., page is negative or exceeds the // available results)? - const isEmpty = !data?.software.length && !data?.meta.has_previous_results; + const isEmpty = + !selfServiceData?.software.length && + !selfServiceData?.meta.has_previous_results && + queryParams.query === ""; + const isEmptySearch = + !selfServiceData?.software.length && + !selfServiceData?.meta.has_previous_results && + queryParams.query !== ""; + + const renderSelfServiceCard = () => { + const renderHeader = () => { + const itemCount = selfServiceData?.count || 0; + + return ( +
+
+ {`${itemCount} ${pluralize(itemCount, "item")}`} +
+
+ +
+
+ ); + }; + + if (isLoading) { + return ( + <> + + + ); + } + + if (isError) { + return ; + } + + if (isEmpty || !selfServiceData) { + return ( + <> + {renderHeader()} + + + ); + } + + if (isFetching) { + return ( + <> + {renderHeader()} + + + ); + } + + if (isEmptySearch) { + return ( + <> + {renderHeader()} + + Not finding what you're looking for?{" "} + + + } + /> + + ); + } + + return ( + <> + {renderHeader()} +
+ {selfServiceData.software.map((s) => { + let uuid = + s.software_package?.last_install?.install_uuid ?? + s.app_store_app?.last_install?.command_uuid; + if (!uuid) { + uuid = ""; + } + const key = `${s.id}${uuid}`; + return ( + + ); + })} +
+ + + ); + }; return ( } /> - {isLoading ? ( - - ) : ( - <> - {isError && } - {!isError && ( -
- {isEmpty ? ( - - ) : ( - <> -
- {`${data.count} ${pluralize(data.count, "item")}`} -
-
- {data.software.map((s) => { - let uuid = - s.software_package?.last_install?.install_uuid ?? - s.app_store_app?.last_install?.command_uuid; - if (!uuid) { - uuid = ""; - } - // concatenating uuid so item updates with fresh data on refetch - const key = `${s.id}${uuid}`; - return ( - - ); - })} -
- - - )} -
- )} - - )} + {renderSelfServiceCard()}
); }; 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 f2907e693f..d181ef3fda 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -16,6 +16,8 @@ import Card from "components/Card"; import Button from "components/buttons/Button"; import Icon from "components/Icon"; import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; +import TooltipTruncatedText from "components/TooltipTruncatedText"; +import Spinner from "components/Spinner"; import { IStatusDisplayConfig } from "../../InstallStatusCell/InstallStatusCell"; @@ -36,7 +38,7 @@ const STATUS_CONFIG: Record< }, pending_install: { iconName: "pending-outline", - displayText: "Pending", + displayText: "Installing...", tooltip: () => "Fleet is installing software.", }, failed_install: { @@ -75,7 +77,7 @@ const InstallerInfo = ({ software }: IInstallerInfoProps) => {
- {name || installerPackage?.name} +
{installerPackage?.version || vppApp?.version || ""} @@ -85,15 +87,26 @@ const InstallerInfo = ({ software }: IInstallerInfoProps) => { ); }; -// TODO: update if/when we support self-service app store apps +interface CommandUuid { + command_uuid: string; +} + +interface InstallUuid { + install_uuid: string; +} + +export type InstallOrCommandUuid = CommandUuid | InstallUuid; + type IInstallerStatusProps = Pick & { last_install: ISoftwareLastInstall | IAppLastInstall | null; + onShowInstallerDetails: (uuid?: InstallOrCommandUuid) => void; }; const InstallerStatus = ({ id, status, last_install, + onShowInstallerDetails, }: IInstallerStatusProps) => { const displayConfig = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]; if (!displayConfig) { @@ -108,9 +121,35 @@ const InstallerStatus = ({ data-tip data-for={`install-tooltip__${id}`} > - + {displayConfig.iconName === "pending-outline" ? ( + + ) : ( + + )} - {displayConfig.displayText} + {last_install && displayConfig.displayText === "Failed" ? ( + + ) : ( + displayConfig.displayText + )}
void; + onShowInstallerDetails: (uuid?: InstallOrCommandUuid) => void; } const getInstallButtonText = (status: SoftwareInstallStatus | null) => { @@ -153,6 +193,7 @@ const InstallerStatusAction = ({ deviceToken, software: { id, status, software_package, app_store_app }, onInstall, + onShowInstallerDetails, }: IInstallerStatusActionProps) => { const { renderFlash } = useContext(NotificationContext); @@ -197,7 +238,12 @@ const InstallerStatusAction = ({ return (
- +
{!!installButtonText && ( @@ -222,12 +268,14 @@ interface ISelfServiceItemProps { deviceToken: string; software: IDeviceSoftware; onInstall: () => void; + onShowInstallerDetails: (uuid?: InstallOrCommandUuid) => void; } const SelfServiceItem = ({ deviceToken, software, onInstall, + onShowInstallerDetails, }: ISelfServiceItemProps) => { return (
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss index 9b4ae26e5e..6cc9eb1fd4 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss @@ -37,6 +37,10 @@ overflow: hidden; text-overflow: ellipsis; text-wrap: nowrap; + + .truncated-tooltip { + font-weight: initial; + } } &__item-version { @@ -85,7 +89,8 @@ } } - &__item-action-button { + &__item-action-button, + &__item-status-button { height: auto; } } diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss index f2003a039a..c6aedf8918 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss @@ -4,8 +4,14 @@ margin: 64px 0; } - &__items-count { + &__header { + display: flex; + justify-content: space-between; + align-items: center; margin: 0 0 24px 0; + } + + &__items-count { font-size: $x-small; font-weight: $bold; } @@ -13,7 +19,24 @@ &__items { display: grid; gap: $pad-large; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + + // Default show 4 cards per row + grid-template-columns: repeat(4, 1fr); + + @media (max-width: ($break-lg - 1px)) { + // For screens under 1400px, show 3 cards per row + grid-template-columns: repeat(3, minmax(250px, 1fr)); + } + + @media (max-width: ($break-md - 1px)) { + // For screens under 990px, show 2 cards per row + grid-template-columns: repeat(2, minmax(250px, 1fr)); + } + + @media (max-width: ($break-mobile-lg - 1px)) { + // For very small screens, show only one card per row + grid-template-columns: repeat(1, minmax(250px, 1fr)); + } } &__pagination { diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index 4ef13c8fe9..0e5b20cbef 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -90,6 +90,22 @@ export default { return sendRequest("POST", path); }, + /** Gets more info on FMA/custom package install for device user */ + getSoftwareInstallResult: (deviceToken: string, uuid: string) => { + const { DEVICE_SOFTWARE_INSTALL_RESULTS } = endpoints; + const path = DEVICE_SOFTWARE_INSTALL_RESULTS(deviceToken, uuid); + + return sendRequest("GET", path); + }, + + /** Gets more info on VPP install for device user */ + getVppCommandResult: (deviceToken: string, uuid: string) => { + const { DEVICE_VPP_COMMAND_RESULTS } = endpoints; + const path = DEVICE_VPP_COMMAND_RESULTS(deviceToken, uuid); + + return sendRequest("GET", path); + }, + getDeviceCertificates: ({ token, page, diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 635ba56576..cac7c45bb0 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -39,6 +39,10 @@ export default { `/${API_VERSION}/fleet/device/${token}/software`, DEVICE_SOFTWARE_INSTALL: (token: string, softwareTitleId: number) => `/${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_VPP_COMMAND_RESULTS: (token: string, uuid: string) => + `/${API_VERSION}/fleet/device/${token}/software/commands/${uuid}/results`, DEVICE_USER_MDM_ENROLLMENT_PROFILE: (token: string): string => { return `/${API_VERSION}/fleet/device/${token}/mdm/apple/manual_enrollment_profile`; },