Add UI for viewing config profile install status and enable resending profiles to failed hosts. (#28964)

For [#28757](https://github.com/fleetdm/fleet/issues/28757)

implements UI for viewing a config profiles install status info as well
as allows user to resend the profile to hosts that it failed on. this
includes

**New info icon on the profile list item**

<img width="618" alt="image"
src="https://github.com/user-attachments/assets/2eb515fe-caea-43da-8e1c-02672825cf84"
/>

**modal to show config profile install status**

<img width="656" alt="image"
src="https://github.com/user-attachments/assets/21b6c483-1d36-40d9-9e5c-a74e7a9fcd50"
/>

**modal to confirm resending profile to failed hosts** 

<img width="666" alt="image"
src="https://github.com/user-attachments/assets/c29d0d24-f6ba-4567-b954-f3908cdfed85"
/>



- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [ ] Added/updated automated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez 2025-05-09 16:46:09 +01:00 committed by GitHub
parent 340d63e0f5
commit b9e321e545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 520 additions and 6 deletions

View file

@ -0,0 +1 @@
- add UI for seeing custom profile status and to batch resend to hosts its failed on.

View file

@ -26,6 +26,8 @@ import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileMod
import ProfileLabelsModal from "./components/ProfileLabelsModal/ProfileLabelsModal";
import ProfileListItem from "./components/ProfileListItem";
import ProfileListHeading from "./components/ProfileListHeading";
import ConfigProfileStatusModal from "./components/ConfigProfileStatusModal";
import ResendConfigProfileModal from "./components/ResendConfigProfileModal";
const PROFILES_PER_PAGE = 10;
@ -59,9 +61,18 @@ const CustomSettings = ({
setProfileLabelsModalData,
] = useState<IMdmProfile | null>(null);
const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false);
const [
showConfigProfileStatusModal,
setShowConfigProfileStatusModal,
] = useState(false);
const [
showResendConfigProfileModal,
setShowResendConfigProfileModal,
] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const selectedProfile = useRef<IMdmProfile | null>(null);
const selectedStatusHostCount = useRef<number | null>(null);
const {
data: profilesData,
@ -96,6 +107,11 @@ const CustomSettings = ({
onMutation();
};
const onCancelInfo = () => {
selectedProfile.current = null;
setShowConfigProfileStatusModal(false);
};
const onCancelDelete = () => {
selectedProfile.current = null;
setShowDeleteProfileModal(false);
@ -129,6 +145,11 @@ const CustomSettings = ({
router.push(path.concat(`${queryString}page=${currentPage + 1}`));
}, [router, path, currentPage, queryString]);
const onClickInfo = (profile: IMdmProfile) => {
selectedProfile.current = profile;
setShowConfigProfileStatusModal(true);
};
const onClickDelete = (profile: IMdmProfile) => {
selectedProfile.current = profile;
setShowDeleteProfileModal(true);
@ -162,7 +183,8 @@ const CustomSettings = ({
isPremium={!!isPremiumTier}
profile={listItem}
setProfileLabelsModalData={setProfileLabelsModalData}
onDelete={onClickDelete}
onClickInfo={onClickInfo}
onClickDelete={onClickDelete}
/>
)}
/>
@ -213,8 +235,8 @@ const CustomSettings = ({
)}
{showDeleteProfileModal && selectedProfile.current && (
<DeleteProfileModal
profileName={selectedProfile.current?.name}
profileId={selectedProfile.current?.profile_uuid}
profileName={selectedProfile.current.name}
profileId={selectedProfile.current.profile_uuid}
onCancel={onCancelDelete}
onDelete={onDeleteProfile}
isDeleting={isDeleting}
@ -226,6 +248,33 @@ const CustomSettings = ({
setModalData={setProfileLabelsModalData}
/>
)}
{showConfigProfileStatusModal && selectedProfile.current && (
<ConfigProfileStatusModal
teamId={currentTeamId}
name={selectedProfile.current.name}
uuid={selectedProfile.current.profile_uuid}
onClickResend={(hostCount) => {
selectedStatusHostCount.current = hostCount;
setShowConfigProfileStatusModal(false);
setShowResendConfigProfileModal(true);
}}
onExit={onCancelInfo}
/>
)}
{showResendConfigProfileModal &&
selectedProfile.current &&
selectedStatusHostCount.current && (
<ResendConfigProfileModal
name={selectedProfile.current.name}
uuid={selectedProfile.current.profile_uuid}
count={selectedStatusHostCount.current}
onExit={() => {
selectedStatusHostCount.current = null;
setShowResendConfigProfileModal(false);
setShowConfigProfileStatusModal(true);
}}
/>
)}
</div>
);
};

View file

@ -0,0 +1,68 @@
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import React from "react";
import { Link } from "react-router";
import PATHS from "router/paths";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { buildQueryStringFromParams } from "utilities/url";
const baseClass = "config-profile-host-count-cell";
interface IConfigProfileHostCountCellProps {
teamId: number;
uuid: string;
status: string;
count: number;
onClickResend: () => void;
}
const ConfigProfileHostCountCell = ({
teamId,
uuid,
status,
count,
onClickResend,
}: IConfigProfileHostCountCellProps) => {
const hostPath = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({
team_id: teamId,
profile_uuid: uuid,
profile_status: status,
})}`;
const renderCount = () => {
if (count === 0) {
return <div>{DEFAULT_EMPTY_CELL_VALUE}</div>;
}
return <Link to={hostPath}>{count}</Link>;
};
const renderResendButton = () => {
// we check if the count is 0 or if the uuid starts with "d" which means it
// is a DDM profile.
if (count === 0 || uuid[0] === "d" || status !== "failed") {
return null;
}
return (
<Button
className={`${baseClass}__resend-button`}
onClick={onClickResend}
variant="text-icon"
>
<Icon name="refresh" color="core-fleet-blue" size="small" />
<span>Resend</span>
</Button>
);
};
return (
<div className={baseClass}>
<>{renderCount()}</>
<>{renderResendButton()}</>
</div>
);
};
export default ConfigProfileHostCountCell;

View file

@ -0,0 +1,12 @@
.config-profile-host-count-cell {
display: flex;
justify-content: space-between;
align-items: center;
.children-wrapper {
.icon {
vertical-align: middle;
margin-right: 8px;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./ConfigProfileHostCountCell";

View file

@ -0,0 +1,72 @@
import React from "react";
import { useQuery } from "react-query";
import configProfileAPI from "services/entities/config_profiles";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import Modal from "components/Modal";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import Button from "components/buttons/Button";
import ConfigProfileStatusTable from "../ConfigProfileStatusTable";
const baseClass = "config-profile-status-modal";
interface IConfigProfileStatusModalProps {
name: string;
uuid: string;
teamId: number;
onClickResend: (hostCount: number) => void;
onExit: () => void;
}
const ConfigProfileStatusModal = ({
name,
uuid,
teamId,
onClickResend,
onExit,
}: IConfigProfileStatusModalProps) => {
const { data, isLoading, isError } = useQuery(
["config-profile-status", uuid],
() => configProfileAPI.getConfigProfileStatus(uuid),
{
...DEFAULT_USE_QUERY_OPTIONS,
}
);
const renderContent = () => {
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <DataError verticalPaddingSize="pad-medium" />;
}
if (!data) {
return null;
}
return (
<ConfigProfileStatusTable
teamId={teamId}
uuid={uuid}
profileStatus={data}
onClickResend={onClickResend}
/>
);
};
return (
<Modal className={baseClass} title={name} onExit={onExit}>
<>
{renderContent()}
<div className="modal-cta-wrap">
<Button onClick={onExit}>Done</Button>
</div>
</>
</Modal>
);
};
export default ConfigProfileStatusModal;

View file

@ -0,0 +1 @@
export { default } from "./ConfigProfileStatusModal";

View file

@ -0,0 +1,51 @@
import React, { useMemo } from "react";
import { IGetConfigProfileStatusResponse } from "services/entities/config_profiles";
import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import {
generateTableConfig,
generateTableData,
} from "./ConfigProfileStatusTableConfig";
const baseClass = "config-profile-status-table";
interface IConfigProfileStatusTableProps {
teamId: number;
uuid: string;
profileStatus: IGetConfigProfileStatusResponse;
onClickResend: (hostCount: number, status: string) => void;
}
const ConfigProfileStatusTable = ({
teamId,
uuid,
profileStatus,
onClickResend,
}: IConfigProfileStatusTableProps) => {
const columnConfigs = useMemo(() => {
return generateTableConfig(teamId, uuid, profileStatus, onClickResend);
}, [profileStatus, teamId, uuid, onClickResend]);
const tableData = generateTableData(profileStatus);
return (
<TableContainer
className={baseClass}
columnConfigs={columnConfigs}
data={tableData}
isLoading={false}
emptyComponent={() => <EmptyTable />}
showMarkAllPages={false}
isAllPagesSelected={false}
manualSortBy
disableTableHeader
disablePagination
disableCount
hideFooter
/>
);
};
export default ConfigProfileStatusTable;

View file

@ -0,0 +1,106 @@
import React from "react";
import { Column } from "react-table";
import StatusIndicatorWithIcon from "components/StatusIndicatorWithIcon";
import {
INumberCellProps,
IStringCellProps,
} from "interfaces/datatable_config";
import { IGetConfigProfileStatusResponse } from "services/entities/config_profiles";
import ConfigProfileHostCountCell from "../ConfigProfileHostCountCell";
type IConfigProfileStatus = "verified" | "verifying" | "pending" | "failed";
interface IConfigProfileRowData {
status: string;
hosts: number;
}
// This is the order in which the statuses will be displayed in the table. It
// will always be in this order.
const STAUTS_ORDER = ["verified", "verifying", "pending", "failed"];
export interface IStatusCellValue {
displayName: string;
statusName: IConfigProfileStatus;
value: IConfigProfileStatus;
}
const STATUS_DISPLAY_OPTIONS = {
verified: {
displayName: "Verified",
statusName: "success",
},
verifying: {
displayName: "Verifying",
statusName: "successPartial",
},
pending: {
displayName: "Pending",
statusName: "pendingPartial",
},
failed: {
displayName: "Failed",
statusName: "error",
},
} as const;
type IConfigProfileStatusColumnConfig = Column<IConfigProfileRowData>;
type IStatusCellProps = IStringCellProps<IConfigProfileRowData>;
type IHostCellProps = INumberCellProps<IConfigProfileRowData>;
export const generateTableConfig = (
teamId: number,
uuid: string,
profileStatus: IGetConfigProfileStatusResponse,
onClickResend: (hostCount: number, status: string) => void
): IConfigProfileStatusColumnConfig[] => {
return [
{
Header: "Status",
disableSortBy: true,
accessor: "status",
Cell: ({ cell: { value } }: IStatusCellProps) => {
const statusOption =
STATUS_DISPLAY_OPTIONS[value as keyof typeof STATUS_DISPLAY_OPTIONS];
return (
<StatusIndicatorWithIcon
status={statusOption.statusName}
value={statusOption.displayName}
/>
);
},
},
{
Header: "Hosts",
accessor: "hosts",
disableSortBy: true,
Cell: ({ cell }: IHostCellProps) => {
return (
<ConfigProfileHostCountCell
teamId={teamId}
count={cell.value}
uuid={uuid}
status={cell.row.original.status}
onClickResend={() =>
onClickResend(cell.value, cell.row.original.status)
}
/>
);
},
},
];
};
export const generateTableData = (
profileStatus: IGetConfigProfileStatusResponse
): IConfigProfileRowData[] => {
const tableData = STAUTS_ORDER.map((status) => ({
status,
hosts: profileStatus[
status as keyof IGetConfigProfileStatusResponse
] as number,
}));
return tableData;
};

View file

@ -0,0 +1,13 @@
.config-profile-status-table {
.config-profile-host-count-cell__resend-button {
transition: opacity 250ms;
opacity: 0;
}
tr:hover {
.config-profile-host-count-cell__resend-button {
opacity: 1;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./ConfigProfileStatusTable";

View file

@ -73,7 +73,8 @@ const createFileContent = async (profile: IMdmProfile) => {
interface IProfileListItemProps {
isPremium: boolean;
profile: IMdmProfile;
onDelete: (profile: IMdmProfile) => void;
onClickInfo: (profile: IMdmProfile) => void;
onClickDelete: (profile: IMdmProfile) => void;
setProfileLabelsModalData: React.Dispatch<
React.SetStateAction<IMdmProfile | null>
>;
@ -82,7 +83,8 @@ interface IProfileListItemProps {
const ProfileListItem = ({
isPremium,
profile,
onDelete,
onClickInfo,
onClickDelete,
setProfileLabelsModalData,
}: IProfileListItemProps) => {
const {
@ -138,6 +140,13 @@ const ProfileListItem = ({
<div className={`${subClass}__actions-wrap`}>
{renderLabelInfo()}
<div className={`${subClass}__actions`}>
<Button
className={`${subClass}__action-button`}
variant="text-icon"
onClick={() => onClickInfo(profile)}
>
<Icon name="info" color="ui-fleet-black-75" size="medium" />
</Button>
{isPremium && labels !== undefined && labels.length && (
<Button
className={`${subClass}__action-button`}
@ -160,7 +169,7 @@ const ProfileListItem = ({
disabled={disableChildren}
className={`${subClass}__action-button`}
variant="text-icon"
onClick={() => onDelete(profile)}
onClick={() => onClickDelete(profile)}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>

View file

@ -0,0 +1,78 @@
import React, { useContext } from "react";
import configProfilesAPI from "services/entities/config_profiles";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import { NotificationContext } from "context/notification";
const baseClass = "resend-config-profile-modal";
interface IResendConfigProfileModalProps {
name: string;
uuid: string;
count: number;
onExit: () => void;
}
const ResendConfigProfileModal = ({
name,
uuid,
count,
onExit,
}: IResendConfigProfileModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isResending, setIsResending] = React.useState(false);
const countText = `${count} ${count === 1 ? "host" : "hosts"}`;
const onClickResend = async () => {
setIsResending(true);
try {
await configProfilesAPI.batchResendConfigProfile(uuid);
renderFlash(
"success",
<>
Resent the <b>{name}</b> configuration profile.
</>
);
} catch (error) {
renderFlash(
"error",
"Couldn't resend the configuration profile. Please try again."
);
}
setIsResending(false);
onExit();
};
return (
<Modal
className={baseClass}
title="Resend configuration profile"
onExit={onExit}
>
<>
<p>
This action will resend the <b>{name}</b> configuration profile to{" "}
<b>{countText}</b>. To cancel after resending, delete and re-add the
profile.
</p>
<div className="modal-cta-wrap">
<Button
onClick={onClickResend}
isLoading={isResending}
disabled={isResending}
>
Resend
</Button>
<Button variant="inverse" onClick={onExit} disabled={isResending}>
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default ResendConfigProfileModal;

View file

@ -0,0 +1 @@
export { default } from "./ResendConfigProfileModal";

View file

@ -0,0 +1,40 @@
import { getConfig } from "@testing-library/react";
import { profile } from "console";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
export interface IGetConfigProfileStatusResponse {
verified: number;
verifying: number;
failed: number;
pending: number;
}
export default {
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,
});
});
},
batchResendConfigProfile: (uuid: string): Promise<void> => {
const { CONFIG_PROFILE_BATCH_RESEND } = endpoints;
const body = {
profile_uuid: uuid,
filters: {
profile_status: "failed",
},
};
return sendRequest("POST", CONFIG_PROFILE_BATCH_RESEND, body);
},
};

View file

@ -270,4 +270,9 @@ export default {
// idp endpoints
SCIM_DETAILS: `/${API_VERSION}/fleet/scim/details`,
// configuration profile endpoints
CONFIG_PROFILE_STATUS: (uuid: string) =>
`/${API_VERSION}/fleet/configuration_profiles/${uuid}/status`,
CONFIG_PROFILE_BATCH_RESEND: `/${API_VERSION}/fleet/configuration_profiles/batch/resend`,
};