Fleet Desktop: Self-service search, status, errors updates (#27731)

This commit is contained in:
RachelElysia 2025-04-30 10:02:09 -04:00 committed by GitHub
parent 8ea1492cd0
commit ea165b65e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 543 additions and 129 deletions

View file

@ -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 (
<Modal
@ -149,7 +158,7 @@ export const AppInstallDetailsModal = ({
>
<>
<div className={`${baseClass}__modal-content`}>
<AppInstallDetails {...details} />
<AppInstallDetails deviceAuthToken={deviceAuthToken} {...details} />
</div>
<div className="modal-cta-wrap">
<Button onClick={onCancel}>Done</Button>

View file

@ -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 (
<Modal

View file

@ -11,6 +11,7 @@ import OrgLogoIcon from "components/icons/OrgLogoIcon";
import validUrl from "components/forms/validators/valid_url";
import SectionHeader from "components/SectionHeader";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import TooltipWrapper from "components/TooltipWrapper";
import { IAppConfigFormProps, IFormField } from "../constants";
@ -142,8 +143,13 @@ const Info = ({
onBlur={validateForm}
error={formErrors.org_logo_url}
inputWrapperClass={`${cardClass}__logo-field`}
tooltip="Logo is displayed in the top bar and other areas of Fleet that
have dark backgrounds."
tooltip={
<>
Logo is displayed in the top bar and other
<br />
areas of Fleet that have dark backgrounds.
</>
}
/>
<div
className={`${cardClass}__icon-preview ${cardClass}__dark-background`}
@ -186,7 +192,20 @@ const Info = ({
error={formErrors.org_name}
/>
<InputField
label="Organization support URL"
label={
<TooltipWrapper
tipContent={
<>
URL is used in &quot;Reach out to IT&quot; links shown to
the end
<br />
user (e.g. self-service and during MDM migration).
</>
}
>
Organization support URL
</TooltipWrapper>
}
onChange={onInputChange}
name="orgSupportURL"
value={orgSupportURL}

View file

@ -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}
/>
</TabPanel>
)}
@ -616,6 +645,30 @@ const DeviceUserPage = ({
}}
/>
)}
{selectedSelfServiceUuid &&
"install_uuid" in selectedSelfServiceUuid &&
!!host && (
<SoftwareInstallDetailsModal
details={{
host_display_name: host.display_name,
install_uuid: selectedSelfServiceUuid.install_uuid,
}}
onCancel={() => setSelectedSelfServiceUuid(undefined)}
deviceAuthToken={deviceAuthToken}
/>
)}
{selectedSelfServiceUuid &&
"command_uuid" in selectedSelfServiceUuid &&
!!host && (
<AppInstallDetailsModal
details={{
host_display_name: host.display_name,
command_uuid: selectedSelfServiceUuid.command_uuid,
}}
onCancel={() => setSelectedSelfServiceUuid(undefined)}
deviceAuthToken={deviceAuthToken}
/>
)}
{selectedSoftwareDetails && !!host && (
<SoftwareDetailsModal
hostDisplayName={host.display_name}

View file

@ -26,7 +26,7 @@ export type IStatusDisplayConfig = {
| "error"
| "install"
| "install-self-service";
displayText: string;
displayText: string | JSX.Element;
tooltip: (args: TootipArgs) => 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
<br />
command to install the app.
</>
) : (
<>
Software is installed (install
<br />
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
<br /> when the host comes online.
</>
),
},
pending_uninstall: {
iconName: "pending-outline",
@ -93,7 +107,7 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
) : (
<>
{softwareName ? <b>{softwareName}</b> : "Software"} can be installed
on the host. Select <b>Actions {">"} Install</b> to install.
on the host. Select <b>Actions &gt; Install</b> to install.
</>
),
},

View file

@ -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(<SelfService {...TEST_PROPS} />);
// 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(<SelfService {...TEST_PROPS} />);
// 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(<SelfService {...TEST_PROPS} />);
// 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(<SelfService {...TEST_PROPS} />);
// 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")

View file

@ -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<typeof parseHostSoftwareQueryParams>;
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<Set<string>>(new Set()); // Track for polling
const pollingTimeoutIdRef = useRef<NodeJS.Timeout | null>(null);
const queryKey = useMemo<IDeviceSoftwareQueryKey[]>(() => {
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 (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__items-count`}>
{`${itemCount} ${pluralize(itemCount, "item")}`}
</div>
<div className={`${baseClass}__search`}>
<SearchField
placeholder="Search by name"
onChange={onSearchQueryChange}
/>
</div>
</div>
);
};
if (isLoading) {
return (
<>
<Spinner />
</>
);
}
if (isError) {
return <DataError />;
}
if (isEmpty || !selfServiceData) {
return (
<>
{renderHeader()}
<EmptyTable
graphicName="empty-software"
header="No self-service software available yet"
info="Your organization didn't add any self-service software. If you need any, reach out to your IT department."
/>
</>
);
}
if (isFetching) {
return (
<>
{renderHeader()}
<Spinner />
</>
);
}
if (isEmptySearch) {
return (
<>
{renderHeader()}
<EmptyTable
graphicName="empty-search-question"
header="No items match the current search criteria"
info={
<>
Not finding what you&apos;re looking for?{" "}
<CustomLink url={contactUrl} text="reach out to IT" newTab />
</>
}
/>
</>
);
}
return (
<>
{renderHeader()}
<div className={`${baseClass}__items`}>
{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 (
<SelfServiceItem
key={key}
deviceToken={deviceToken}
software={s}
onInstall={onInstall}
onShowInstallerDetails={onShowInstallerDetails}
/>
);
})}
</div>
<Pagination
disableNext={selfServiceData.meta.has_next_results === false}
disablePrev={selfServiceData.meta.has_previous_results === false}
hidePagination={
selfServiceData.meta.has_next_results === false &&
selfServiceData.meta.has_previous_results === false
}
onNextPage={onNextPage}
onPrevPage={onPrevPage}
className={`${baseClass}__pagination`}
/>
</>
);
};
return (
<Card
@ -108,61 +372,7 @@ const SoftwareSelfService = ({
</>
}
/>
{isLoading ? (
<Spinner />
) : (
<>
{isError && <DataError />}
{!isError && (
<div className={baseClass}>
{isEmpty ? (
<EmptyTable
graphicName="empty-software"
header="No self-service software available yet"
info="Your organization didn't add any self-service software. If you need any, reach out to your IT department."
/>
) : (
<>
<div className={`${baseClass}__items-count`}>
<b>{`${data.count} ${pluralize(data.count, "item")}`}</b>
</div>
<div className={`${baseClass}__items`}>
{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 (
<SelfServiceItem
key={key}
deviceToken={deviceToken}
software={s}
onInstall={refetch}
/>
);
})}
</div>
<Pagination
disableNext={data.meta.has_next_results === false}
disablePrev={data.meta.has_previous_results === false}
hidePagination={
data.meta.has_next_results === false &&
data.meta.has_previous_results === false
}
onNextPage={onNextPage}
onPrevPage={onPrevPage}
className={`${baseClass}__pagination`}
/>
</>
)}
</div>
)}
</>
)}
{renderSelfServiceCard()}
</Card>
);
};

View file

@ -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) => {
</div>
<div className={`${baseClass}__item-name-version`}>
<div className={`${baseClass}__item-name`}>
{name || installerPackage?.name}
<TooltipTruncatedText value={name || installerPackage?.name} />
</div>
<div className={`${baseClass}__item-version`}>
{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<IHostSoftware, "id" | "status"> & {
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}`}
>
<Icon name={displayConfig.iconName} />
{displayConfig.iconName === "pending-outline" ? (
<Spinner size="x-small" includeContainer={false} centered={false} />
) : (
<Icon name={displayConfig.iconName} />
)}
<span data-testid={`${baseClass}__status--test`}>
{displayConfig.displayText}
{last_install && displayConfig.displayText === "Failed" ? (
<Button
className={`${baseClass}__item-status-button`}
variant="text-icon"
onClick={() => {
if ("command_uuid" in last_install) {
onShowInstallerDetails({
command_uuid: last_install.command_uuid,
});
} else if ("install_uuid" in last_install) {
onShowInstallerDetails({
install_uuid: last_install.install_uuid,
});
} else {
onShowInstallerDetails(undefined);
}
}}
>
{displayConfig.displayText}
</Button>
) : (
displayConfig.displayText
)}
</span>
</div>
<ReactTooltip
@ -134,6 +173,7 @@ interface IInstallerStatusActionProps {
deviceToken: string;
software: IHostSoftware;
onInstall: () => 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 (
<div className={`${baseClass}__item-status-action`}>
<div className={`${baseClass}__item-status`}>
<InstallerStatus id={id} status={status} last_install={lastInstall} />
<InstallerStatus
id={id}
status={status}
last_install={lastInstall}
onShowInstallerDetails={onShowInstallerDetails}
/>
</div>
<div className={`${baseClass}__item-action`}>
{!!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 (
<Card
@ -241,6 +289,7 @@ const SelfServiceItem = ({
deviceToken={deviceToken}
software={software}
onInstall={onInstall}
onShowInstallerDetails={onShowInstallerDetails}
/>
</div>
</Card>

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -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,

View file

@ -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`;
},