Feat UI host filter by custom profiles (#29038)

For #28759

This is the UI work for being able to filter hosts by a configuration
profile status. There are also added tests in this PR.


![image](https://github.com/user-attachments/assets/b2585093-b191-4dc5-a11e-55ad4156d713)


- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] Added/updated automated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez 2025-05-15 12:37:45 +01:00 committed by GitHub
parent e637e7e1a7
commit 5815dc4e54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 275 additions and 47 deletions

View file

@ -0,0 +1 @@
- add UI to filter hosts by config profile status.

View file

@ -0,0 +1,71 @@
import React from "react";
import { screen, within } from "@testing-library/react";
import { noop } from "lodash";
import { createCustomRenderer } from "test/test-utils";
import mockServer from "test/mock-server";
import { defaultConfigProfileStatusHandler } from "test/handlers/config-profiles";
import ConfigProfileStatusModal from "./ConfigProfileStatusModal";
describe("ConfigProfileStatusModal", () => {
const render = createCustomRenderer({
withBackendMock: true,
});
it("renders the correct number of hosts for each status", async () => {
mockServer.use(defaultConfigProfileStatusHandler);
render(
<ConfigProfileStatusModal
name="Test profile"
uuid="123-abc"
teamId={0}
onClickResend={noop}
onExit={noop}
/>
);
await screen.findByText("Verified");
// get all rows in the table and skip header row
const rows = screen.getAllByRole("row").slice(1);
const verifiedRow = within(rows[0]).getAllByRole("cell");
expect(verifiedRow[0]).toHaveTextContent("Verified");
expect(verifiedRow[1]).toHaveTextContent("---");
const verifiyingRow = within(rows[1]).getAllByRole("cell");
expect(verifiyingRow[0]).toHaveTextContent("Verifying");
expect(verifiyingRow[1]).toHaveTextContent("1");
const pendingRow = within(rows[2]).getAllByRole("cell");
expect(pendingRow[0]).toHaveTextContent("Pending");
expect(pendingRow[1]).toHaveTextContent("2");
const failedRow = within(rows[3]).getAllByRole("cell");
expect(failedRow[0]).toHaveTextContent("Failed");
expect(failedRow[1]).toHaveTextContent("3");
});
it("shows the resend button for a failed row on hover", async () => {
mockServer.use(defaultConfigProfileStatusHandler);
const { user } = render(
<ConfigProfileStatusModal
name="Test profile"
uuid="123-abc"
teamId={0}
onClickResend={noop}
onExit={noop}
/>
);
await screen.findByText("Verified");
const failedRow = screen.getByText("Failed").closest("tr");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user.hover(failedRow!);
const resendButton = screen.getByRole("button", { name: "Resend" });
expect(resendButton).toBeVisible();
});
});

View file

@ -1,15 +1,17 @@
import React from "react";
import { Column } from "react-table";
import StatusIndicatorWithIcon from "components/StatusIndicatorWithIcon";
import {
INumberCellProps,
IStringCellProps,
} from "interfaces/datatable_config";
import { MdmProfileStatus } from "interfaces/mdm";
import { IGetConfigProfileStatusResponse } from "services/entities/config_profiles";
import ConfigProfileHostCountCell from "../ConfigProfileHostCountCell";
type IConfigProfileStatus = "verified" | "verifying" | "pending" | "failed";
import StatusIndicatorWithIcon from "components/StatusIndicatorWithIcon";
import { IndicatorStatus } from "components/StatusIndicatorWithIcon/StatusIndicatorWithIcon";
import ConfigProfileHostCountCell from "../ConfigProfileHostCountCell";
interface IConfigProfileRowData {
status: string;
@ -22,11 +24,10 @@ const STAUTS_ORDER = ["verified", "verifying", "pending", "failed"];
export interface IStatusCellValue {
displayName: string;
statusName: IConfigProfileStatus;
value: IConfigProfileStatus;
statusName: IndicatorStatus;
}
const STATUS_DISPLAY_OPTIONS = {
const STATUS_DISPLAY_OPTIONS: Record<MdmProfileStatus, IStatusCellValue> = {
verified: {
displayName: "Verified",
statusName: "success",
@ -43,7 +44,7 @@ const STATUS_DISPLAY_OPTIONS = {
displayName: "Failed",
statusName: "error",
},
} as const;
};
type IConfigProfileStatusColumnConfig = Column<IConfigProfileRowData>;
type IStatusCellProps = IStringCellProps<IConfigProfileRowData>;

View file

@ -19,7 +19,7 @@ const generateMessageSuffix = (isPremiumTier?: boolean, teamId?: number) => {
if (!isPremiumTier) {
return "";
}
return teamId ? " assigned to this team" : " with no team";
return teamId ? "assigned to this team" : "with no team";
};
const DeleteProfileModal = ({
@ -42,11 +42,13 @@ const DeleteProfileModal = ({
width="large"
>
<>
<p>
This action will delete configuration profile{" "}
<span className={`${baseClass}__profile-name`}>{profileName}</span>{" "}
from all hosts{messageSuffix}.
</p>
<div className={`${baseClass}__content`}>
<p>
This action will remove the <b>{profileName}</b> configuration
profile from all hosts {messageSuffix}.
</p>
<p>Pending profiles will be canceled.</p>
</div>
<div className="modal-cta-wrap">
<Button
type="button"

View file

@ -1,4 +1,14 @@
.delete-profile-modal {
&__content {
display: flex;
flex-direction: column;
gap: $pad-large;
p {
margin: 0;
}
}
&__profile-name {
font-weight: $bold;
}

View file

@ -36,6 +36,7 @@ const ResendConfigProfileModal = ({
Resent the <b>{name}</b> configuration profile.
</>
);
onExit();
} catch (error) {
renderFlash(
"error",
@ -43,7 +44,6 @@ const ResendConfigProfileModal = ({
);
}
setIsResending(false);
onExit();
};
return (

View file

@ -30,6 +30,9 @@ import hostCountAPI, {
IHostsCountQueryKey,
IHostsCountResponse,
} from "services/entities/host_count";
import configProfileAPI, {
IGetConfigProfileResponse,
} from "services/entities/config_profiles";
import {
getOSVersions,
@ -281,6 +284,8 @@ const ManageHostsPage = ({
queryParams?.[PARAMS.DISK_ENCRYPTION];
const bootstrapPackageStatus: BootstrapPackageStatus | undefined =
queryParams?.bootstrap_package;
const configProfileStatus = queryParams?.profile_status;
const configProfileUUID = queryParams?.profile_uuid;
// ========= routeParams
const { active_label: activeLabel, label_id: labelID } = routeParams;
@ -368,6 +373,19 @@ const ManageHostsPage = ({
}
);
const {
data: configProfile,
isLoading: isLoadingConfigProfile,
error: errorConfigProfile,
} = useQuery<IGetConfigProfileResponse, Error>(
["config-profile", configProfileUUID],
() => configProfileAPI.getConfigProfile(configProfileUUID),
{
enabled: isRouteOk && !!configProfileUUID,
// select: (data) => data.policy,
}
);
const { data: osVersions } = useQuery<
IOSVersionsResponse,
Error,
@ -422,6 +440,8 @@ const ManageHostsPage = ({
diskEncryptionStatus,
bootstrapPackageStatus,
macSettingsStatus,
configProfileStatus,
configProfileUUID,
},
],
({ queryKey }) => hostsAPI.loadHosts(queryKey[0]),
@ -463,6 +483,8 @@ const ManageHostsPage = ({
diskEncryptionStatus,
bootstrapPackageStatus,
macSettingsStatus,
configProfileStatus,
configProfileUUID,
},
],
({ queryKey }) => hostCountAPI.load(queryKey[0]),
@ -502,7 +524,8 @@ const ManageHostsPage = ({
refetchHostsCountAPI();
};
const hasErrors = !!errorHosts || !!errorHostsCount || !!errorPolicy;
const hasErrors =
!!errorHosts || !!errorHostsCount || !!errorPolicy || !!errorConfigProfile;
const toggleDeleteSecretModal = () => {
// open and closes delete modal
@ -757,6 +780,22 @@ const ManageHostsPage = ({
);
};
const handleConfigProfileStatusChange = (newStatus: string) => {
router.replace(
getNextLocationPath({
pathPrefix: PATHS.MANAGE_HOSTS,
routeTemplate,
routeParams,
queryParams: {
...queryParams,
profile_status: newStatus,
profile_uuid: configProfileUUID,
page: 0, // resets page index
},
})
);
};
const handleRowSelect = (row: IRowProps) => {
if (row.original.id) {
const path = PATHS.HOST_DETAILS(row.original.id);
@ -894,6 +933,9 @@ const ManageHostsPage = ({
newQueryParams[PARAMS.DISK_ENCRYPTION] = diskEncryptionStatus;
} else if (bootstrapPackageStatus && isPremiumTier) {
newQueryParams.bootstrap_package = bootstrapPackageStatus;
} else if (configProfileStatus && configProfileUUID) {
newQueryParams.profile_status = configProfileStatus;
newQueryParams.profile_uuid = configProfileUUID;
}
router.replace(
@ -918,7 +960,6 @@ const ManageHostsPage = ({
softwareId,
softwareVersionId,
softwareTitleId,
softwareStatus,
mdmId,
mdmEnrollmentStatus,
munkiIssueId,
@ -928,13 +969,16 @@ const ManageHostsPage = ({
osVersionId,
osName,
osVersion,
router,
routeTemplate,
routeParams,
vulnerability,
osSettingsStatus,
diskEncryptionStatus,
bootstrapPackageStatus,
vulnerability,
configProfileStatus,
configProfileUUID,
router,
routeTemplate,
routeParams,
softwareStatus,
]
);
@ -1610,7 +1654,12 @@ const ManageHostsPage = ({
resultsTitle="hosts"
columnConfigs={tableColumns}
data={hostsData?.hosts || []}
isLoading={isLoadingHosts || isLoadingHostsCount || isLoadingPolicy}
isLoading={
isLoadingHosts ||
isLoadingHostsCount ||
isLoadingPolicy ||
isLoadingConfigProfile
}
manualSortBy
defaultSortHeader={(sortBy[0] && sortBy[0].key) || DEFAULT_SORT_HEADER}
defaultSortDirection={
@ -1746,6 +1795,9 @@ const ManageHostsPage = ({
diskEncryptionStatus,
bootstrapPackageStatus,
vulnerability,
configProfileStatus,
configProfileUUID,
configProfile,
}}
selectedLabel={selectedLabel}
isOnlyObserver={isOnlyObserver}
@ -1763,6 +1815,7 @@ const ManageHostsPage = ({
onChangeSoftwareInstallStatusFilter={
handleSoftwareInstallStatusChange
}
onChangeConfigProfileStatusFilter={handleConfigProfileStatusChange}
onClickEditLabel={onEditLabelClick}
onClickDeleteLabel={toggleDeleteLabelModal}
/>

View file

@ -1,12 +1,13 @@
import React, { ReactNode } from "react";
import React, { ReactNode, useRef } from "react";
import ReactTooltip from "react-tooltip";
import classnames from "classnames";
import Button from "components/buttons/Button";
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
import { COLORS } from "styles/var/colors";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import { IconNames } from "components/icons";
import { COLORS } from "styles/var/colors";
interface IFilterPillProps {
label: string;
@ -30,6 +31,9 @@ const FilterPill = ({
tooltip: tooltipDescription !== undefined && tooltipDescription !== "",
});
const pillText = useRef(null);
const isTuncated = useCheckTruncatedElement(pillText);
return (
<div
className={baseClasses}
@ -43,6 +47,8 @@ const FilterPill = ({
<span
data-tip={tooltipDescription}
data-for={`filter-pill-tooltip-${label}`}
className={`${baseClass}__tooltip-text`}
ref={pillText}
>
{label}
</span>
@ -59,11 +65,12 @@ const FilterPill = ({
{tooltipDescription && (
<ReactTooltip
role="tooltip"
place="bottom"
place="top"
effect="solid"
backgroundColor={COLORS["tooltip-bg"]}
id={`filter-pill-tooltip-${label}`}
data-html
disable={!isTuncated}
>
<span>{tooltipDescription}</span>
</ReactTooltip>

View file

@ -23,4 +23,8 @@
}
}
}
&__tooltip-text {
@include ellipse-text(166px);
}
}

View file

@ -13,6 +13,7 @@ import {
IMdmSolution,
MDM_ENROLLMENT_STATUS,
MdmProfileStatus,
IMdmProfile,
} from "interfaces/mdm";
import { IMunkiIssuesAggregate } from "interfaces/macadmins";
import { IPolicy } from "interfaces/policy";
@ -77,6 +78,9 @@ interface IHostsFilterBlockProps {
diskEncryptionStatus?: DiskEncryptionStatus;
bootstrapPackageStatus?: BootstrapPackageStatus;
softwareStatus?: SoftwareAggregateStatus;
configProfileStatus?: string;
configProfileUUID?: string;
configProfile?: IMdmProfile;
};
selectedLabel?: ILabel;
isOnlyObserver?: boolean;
@ -94,6 +98,7 @@ interface IHostsFilterBlockProps {
onChangeSoftwareInstallStatusFilter: (
newStatus: SoftwareAggregateStatus
) => void;
onChangeConfigProfileStatusFilter: (newStatus: string) => void;
onClickEditLabel: (evt: React.MouseEvent<HTMLButtonElement>) => void;
onClickDeleteLabel: () => void;
}
@ -127,6 +132,9 @@ const HostsFilterBlock = ({
diskEncryptionStatus,
bootstrapPackageStatus,
softwareStatus,
configProfileStatus,
configProfileUUID,
configProfile,
},
selectedLabel,
isOnlyObserver,
@ -138,6 +146,7 @@ const HostsFilterBlock = ({
onChangeBootstrapPackageStatusFilter,
onChangeMacSettingsFilter,
onChangeSoftwareInstallStatusFilter,
onChangeConfigProfileStatusFilter,
onClickEditLabel,
onClickDeleteLabel,
}: IHostsFilterBlockProps) => {
@ -308,19 +317,10 @@ const HostsFilterBlock = ({
clearParams.push(...additionalClearParams);
}
// const TooltipDescription = (
// <span>
// Hosts with {name || "Unknown software"},
// <br />
// {version || "version unknown"} installed
// </span>
// );
return (
<FilterPill
label={label}
onClear={() => handleClearFilter(clearParams)}
// tooltipDescription={TooltipDescription}
/>
);
};
@ -511,6 +511,38 @@ const HostsFilterBlock = ({
);
};
const renderConfigProfileStatusBlock = () => {
const OPTIONS = [
{ value: "verified", label: "Verified" },
{ value: "verifying", label: "Verifying" },
{ value: "pending", label: "Pending" },
{ value: "failed", label: "Failed" },
];
return (
<>
<Dropdown
value={configProfileStatus}
className={`${baseClass}__config-profile-status-dropdown`}
options={OPTIONS}
searchable={false}
onChange={onChangeConfigProfileStatusFilter}
iconName="filter-alt"
/>
<FilterPill
label={`OS settings: ${configProfile?.name}`}
onClear={() => handleClearFilter(["profile_status", "profile_uuid"])}
tooltipDescription={
<>
OS settings:
<br />
{configProfile?.name}
</>
}
/>
</>
);
};
const showSelectedLabel = selectedLabel && selectedLabel.type !== "all";
if (
@ -530,7 +562,8 @@ const HostsFilterBlock = ({
osSettingsStatus ||
diskEncryptionStatus ||
bootstrapPackageStatus ||
vulnerability
vulnerability ||
(configProfileStatus && configProfileUUID && configProfile)
) {
const renderFilterPill = () => {
switch (true) {
@ -583,6 +616,8 @@ const HostsFilterBlock = ({
return renderDiskEncryptionStatusBlock();
case !!bootstrapPackageStatus:
return renderBootstrapPackageStatusBlock();
case !!configProfileStatus && !!configProfileUUID && !!configProfile:
return renderConfigProfileStatusBlock();
default:
return null;
}

View file

@ -1,8 +1,9 @@
import { getConfig } from "@testing-library/react";
import { profile } from "console";
import { IMdmProfile } from "interfaces/mdm";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
export type IGetConfigProfileResponse = IMdmProfile;
export interface IGetConfigProfileStatusResponse {
verified: number;
verifying: number;
@ -11,20 +12,16 @@ export interface IGetConfigProfileStatusResponse {
}
export default {
getConfigProfile: (uuid: string): Promise<IGetConfigProfileResponse> => {
const { CONFIG_PROFILE } = endpoints;
return sendRequest("GET", CONFIG_PROFILE(uuid));
},
getConfigProfileStatus: (
uuid: string
): Promise<IGetConfigProfileStatusResponse> => {
const { CONFIG_PROFILE_STATUS } = endpoints;
// return sendRequest("GET", CONFIG_PROFILE_STATUS(uuid));
return new Promise((resolve) => {
resolve({
verified: 0,
verifying: 1,
failed: 2,
pending: 3,
});
});
return sendRequest("GET", CONFIG_PROFILE_STATUS(uuid));
},
batchResendConfigProfile: (uuid: string): Promise<void> => {

View file

@ -54,6 +54,8 @@ export interface IHostCountLoadOptions {
vulnerability?: string;
diskEncryptionStatus?: DiskEncryptionStatus;
bootstrapPackageStatus?: BootstrapPackageStatus;
configProfileStatus?: string;
configProfileUUID?: string;
}
export default {
@ -83,6 +85,8 @@ export default {
const vulnerability = options?.vulnerability;
const diskEncryptionStatus = options?.diskEncryptionStatus;
const bootstrapPackageStatus = options?.bootstrapPackageStatus;
const configProfileStatus = options?.configProfileStatus;
const configProfileUUID = options?.configProfileUUID;
const queryParams = {
query: globalFilter,
@ -113,6 +117,8 @@ export default {
vulnerability,
diskEncryptionStatus,
bootstrapPackageStatus,
configProfileStatus,
configProfileUUID,
}),
label_id: label,
status,

View file

@ -90,6 +90,8 @@ export interface ILoadHostsOptions {
osSettings?: MdmProfileStatus;
diskEncryptionStatus?: DiskEncryptionStatus;
bootstrapPackageStatus?: BootstrapPackageStatus;
configProfileStatus?: string;
configProfileUUID?: string;
}
export interface IExportHostsOptions {
@ -411,6 +413,8 @@ export default {
osSettings,
diskEncryptionStatus,
bootstrapPackageStatus,
configProfileStatus,
configProfileUUID,
}: ILoadHostsOptions): Promise<ILoadHostsResponse> => {
const label = getLabel(selectedLabels);
const sortParams = getSortParams(sortBy);
@ -449,6 +453,8 @@ export default {
diskEncryptionStatus,
osSettings,
bootstrapPackageStatus,
configProfileStatus,
configProfileUUID,
}),
};

View file

@ -0,0 +1,24 @@
import createMockConfig from "__mocks__/configMock";
import { IConfig } from "interfaces/config";
import { http, HttpResponse } from "msw";
import { baseUrl } from "test/test-utils";
const configProfileURL = baseUrl("/configuration_profiles");
export const createGetConfigHandler = (overrides?: Partial<IConfig>) => {
return http.get(configProfileURL, () => {
return HttpResponse.json(createMockConfig({ ...overrides }));
});
};
export const defaultConfigProfileStatusHandler = http.get(
`${configProfileURL}/:uuid/status`,
() => {
return HttpResponse.json({
verified: 0,
verifying: 1,
pending: 2,
failed: 3,
});
}
);

View file

@ -274,6 +274,8 @@ export default {
SCIM_DETAILS: `/${API_VERSION}/fleet/scim/details`,
// configuration profile endpoints
CONFIG_PROFILE: (uuid: string) =>
`/${API_VERSION}/fleet/configuration_profiles/${uuid}`,
CONFIG_PROFILE_STATUS: (uuid: string) =>
`/${API_VERSION}/fleet/configuration_profiles/${uuid}/status`,
CONFIG_PROFILE_BATCH_RESEND: `/${API_VERSION}/fleet/configuration_profiles/batch/resend`,

View file

@ -46,6 +46,8 @@ interface IMutuallyExclusiveHostParams {
osSettings?: MdmProfileStatus;
diskEncryptionStatus?: DiskEncryptionStatus;
bootstrapPackageStatus?: BootstrapPackageStatus;
configProfileStatus?: string;
configProfileUUID?: string;
}
export const parseQueryValueToNumberOrUndefined = (
@ -216,6 +218,8 @@ export const reconcileMutuallyExclusiveHostParams = ({
vulnerability,
diskEncryptionStatus,
bootstrapPackageStatus,
configProfileStatus,
configProfileUUID,
}: IMutuallyExclusiveHostParams): Record<string, unknown> => {
if (label) {
// backend api now allows (label + low disk space) OR (label + mdm id) OR
@ -270,6 +274,11 @@ export const reconcileMutuallyExclusiveHostParams = ({
return { [HOSTS_QUERY_PARAMS.DISK_ENCRYPTION]: diskEncryptionStatus };
case !!bootstrapPackageStatus:
return { bootstrap_package: bootstrapPackageStatus };
case !!configProfileUUID:
return {
profile_status: configProfileStatus,
profile_uuid: configProfileUUID,
};
default:
return {};
}