mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Fleet Desktop: Self-service search, status, errors updates (#27731)
This commit is contained in:
parent
8ea1492cd0
commit
ea165b65e2
12 changed files with 543 additions and 129 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "Reach out to IT" 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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 > Install</b> to install.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue