diff --git a/changes/issue-28757-ui-for-profiles-status-and-batch-resend b/changes/issue-28757-ui-for-profiles-status-and-batch-resend new file mode 100644 index 0000000000..a6e90cd769 --- /dev/null +++ b/changes/issue-28757-ui-for-profiles-status-and-batch-resend @@ -0,0 +1 @@ +- add UI for seeing custom profile status and to batch resend to hosts its failed on. diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx index ac1c3c6906..9a5116225a 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx @@ -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(null); const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false); + const [ + showConfigProfileStatusModal, + setShowConfigProfileStatusModal, + ] = useState(false); + const [ + showResendConfigProfileModal, + setShowResendConfigProfileModal, + ] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const selectedProfile = useRef(null); + const selectedStatusHostCount = useRef(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 && ( )} + {showConfigProfileStatusModal && selectedProfile.current && ( + { + selectedStatusHostCount.current = hostCount; + setShowConfigProfileStatusModal(false); + setShowResendConfigProfileModal(true); + }} + onExit={onCancelInfo} + /> + )} + {showResendConfigProfileModal && + selectedProfile.current && + selectedStatusHostCount.current && ( + { + selectedStatusHostCount.current = null; + setShowResendConfigProfileModal(false); + setShowConfigProfileStatusModal(true); + }} + /> + )} ); }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/ConfigProfileHostCountCell.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/ConfigProfileHostCountCell.tsx new file mode 100644 index 0000000000..0011b417a0 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/ConfigProfileHostCountCell.tsx @@ -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
{DEFAULT_EMPTY_CELL_VALUE}
; + } + + return {count}; + }; + + 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 ( + + ); + }; + + return ( +
+ <>{renderCount()} + <>{renderResendButton()} +
+ ); +}; + +export default ConfigProfileHostCountCell; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/_styles.scss new file mode 100644 index 0000000000..ca229dcdc9 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/_styles.scss @@ -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; + } + } +} diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/index.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/index.ts new file mode 100644 index 0000000000..7020091290 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileHostCountCell/index.ts @@ -0,0 +1 @@ +export { default } from "./ConfigProfileHostCountCell"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/ConfigProfileStatusModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/ConfigProfileStatusModal.tsx new file mode 100644 index 0000000000..42c2112d4b --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/ConfigProfileStatusModal.tsx @@ -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 ; + } + if (isError) { + return ; + } + + if (!data) { + return null; + } + + return ( + + ); + }; + + return ( + + <> + {renderContent()} +
+ +
+ +
+ ); +}; + +export default ConfigProfileStatusModal; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/_styles.scss new file mode 100644 index 0000000000..a306ea6587 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/_styles.scss @@ -0,0 +1,3 @@ +.restrictions-modal { + +} diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/index.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/index.ts new file mode 100644 index 0000000000..882e601f74 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ConfigProfileStatusModal"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTable.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTable.tsx new file mode 100644 index 0000000000..3fb46df0ea --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTable.tsx @@ -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 ( + } + showMarkAllPages={false} + isAllPagesSelected={false} + manualSortBy + disableTableHeader + disablePagination + disableCount + hideFooter + /> + ); +}; + +export default ConfigProfileStatusTable; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTableConfig.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTableConfig.tsx new file mode 100644 index 0000000000..2ad7ee60b1 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTableConfig.tsx @@ -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; +type IStatusCellProps = IStringCellProps; +type IHostCellProps = INumberCellProps; + +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 ( + + ); + }, + }, + { + Header: "Hosts", + accessor: "hosts", + disableSortBy: true, + Cell: ({ cell }: IHostCellProps) => { + return ( + + 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; +}; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/_styles.scss new file mode 100644 index 0000000000..8f25a70f93 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/_styles.scss @@ -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; + } + } +} diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/index.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/index.ts new file mode 100644 index 0000000000..c8b28274e6 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/index.ts @@ -0,0 +1 @@ +export { default } from "./ConfigProfileStatusTable"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileListItem/ProfileListItem.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileListItem/ProfileListItem.tsx index 5fe421f79e..99c9f4c0a8 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileListItem/ProfileListItem.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileListItem/ProfileListItem.tsx @@ -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 >; @@ -82,7 +83,8 @@ interface IProfileListItemProps { const ProfileListItem = ({ isPremium, profile, - onDelete, + onClickInfo, + onClickDelete, setProfileLabelsModalData, }: IProfileListItemProps) => { const { @@ -138,6 +140,13 @@ const ProfileListItem = ({
{renderLabelInfo()}
+ {isPremium && labels !== undefined && labels.length && ( diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/ResendConfigProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/ResendConfigProfileModal.tsx new file mode 100644 index 0000000000..1a8178a2ae --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/ResendConfigProfileModal.tsx @@ -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 {name} configuration profile. + + ); + } catch (error) { + renderFlash( + "error", + "Couldn't resend the configuration profile. Please try again." + ); + } + setIsResending(false); + onExit(); + }; + + return ( + + <> +

+ This action will resend the {name} configuration profile to{" "} + {countText}. To cancel after resending, delete and re-add the + profile. +

+
+ + +
+ +
+ ); +}; + +export default ResendConfigProfileModal; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/_styles.scss new file mode 100644 index 0000000000..f295ab176e --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/_styles.scss @@ -0,0 +1,3 @@ +.resend-config-profile-modal { + +} diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/index.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/index.ts new file mode 100644 index 0000000000..d44d0aba5f --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ResendConfigProfileModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ResendConfigProfileModal"; diff --git a/frontend/services/entities/config_profiles.ts b/frontend/services/entities/config_profiles.ts new file mode 100644 index 0000000000..160de6a526 --- /dev/null +++ b/frontend/services/entities/config_profiles.ts @@ -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 => { + 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 => { + const { CONFIG_PROFILE_BATCH_RESEND } = endpoints; + const body = { + profile_uuid: uuid, + filters: { + profile_status: "failed", + }, + }; + return sendRequest("POST", CONFIG_PROFILE_BATCH_RESEND, body); + }, +}; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index cac7c45bb0..053070da6e 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -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`, };