mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 17:08:53 +00:00
add UI to add the include any option for custom profile custom label target (#23390)
relates to #22575 Add the UI for allowing custom profiles to be uploaded to hosts that have any of specified labels. I also spent some time cleaning up the custom settings card and its components. I was able to remove a lot of unused styles.  <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [ ] Added/updated tests - [x] Manual QA for all new/changed functionality
This commit is contained in:
parent
5331019735
commit
ccbdf46119
13 changed files with 158 additions and 250 deletions
2
changes/22575-ui-for-include-any-labels
Normal file
2
changes/22575-ui-for-include-any-labels
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- add UI for allowing users to install custom profiles on hosts that include any of the defined
|
||||
labels
|
||||
|
|
@ -111,6 +111,7 @@ export interface IMdmProfile {
|
|||
updated_at: string;
|
||||
checksum: string | null; // null for windows profiles
|
||||
labels_include_all?: IProfileLabel[];
|
||||
labels_include_any?: IProfileLabel[];
|
||||
labels_exclude_any?: IProfileLabel[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import Pagination from "pages/ManageControlsPage/components/Pagination";
|
|||
import UploadList from "../../../components/UploadList";
|
||||
|
||||
import AddProfileCard from "./components/ProfileUploader/components/AddProfileCard";
|
||||
import AddProfileModal from "./components/ProfileUploader/components/AddProfileModal/AddProfileModal";
|
||||
import AddProfileModal from "./components/ProfileUploader/components/AddProfileModal";
|
||||
import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal";
|
||||
import ProfileLabelsModal from "./components/ProfileLabelsModal/ProfileLabelsModal";
|
||||
import ProfileListItem from "./components/ProfileListItem";
|
||||
|
|
@ -136,7 +136,7 @@ const CustomSettings = ({
|
|||
}
|
||||
|
||||
if (!profiles?.length) {
|
||||
return null;
|
||||
return <AddProfileCard setShowModal={setShowAddProfileModal} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -144,11 +144,11 @@ const CustomSettings = ({
|
|||
<UploadList
|
||||
keyAttribute="profile_uuid"
|
||||
listItems={profiles}
|
||||
HeadingComponent={() =>
|
||||
ProfileListHeading({
|
||||
onClickAddProfile: () => setShowAddProfileModal(true),
|
||||
})
|
||||
}
|
||||
HeadingComponent={() => (
|
||||
<ProfileListHeading
|
||||
onClickAddProfile={() => setShowAddProfileModal(true)}
|
||||
/>
|
||||
)}
|
||||
ListItemComponent={({ listItem }) => (
|
||||
<ProfileListItem
|
||||
isPremium={!!isPremiumTier}
|
||||
|
|
@ -184,13 +184,7 @@ const CustomSettings = ({
|
|||
url="https://fleetdm.com/learn-more-about/custom-os-settings"
|
||||
/>
|
||||
</p>
|
||||
{renderProfileList()}
|
||||
{!isLoadingProfiles && !isErrorProfiles && !profiles?.length && (
|
||||
<AddProfileCard
|
||||
baseClass="add-profile"
|
||||
setShowModal={setShowAddProfileModal}
|
||||
/>
|
||||
)}
|
||||
<>{renderProfileList()}</>
|
||||
{showAddProfileModal && (
|
||||
<AddProfileModal
|
||||
currentTeamId={currentTeamId}
|
||||
|
|
@ -209,7 +203,6 @@ const CustomSettings = ({
|
|||
)}
|
||||
{isPremiumTier && hasLabels && (
|
||||
<ProfileLabelsModal
|
||||
baseClass={baseClass}
|
||||
profile={profileLabelsModalData}
|
||||
setModalData={setProfileLabelsModalData}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,69 +1,15 @@
|
|||
.custom-settings {
|
||||
.section-header {
|
||||
margin: 0;
|
||||
padding: 0 0 12px 0;
|
||||
|
||||
h2 {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: $x-small;
|
||||
margin: $pad-large 0;
|
||||
}
|
||||
|
||||
&__profiles-header {
|
||||
padding: $pad-medium $pad-large;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
}
|
||||
|
||||
&__profile-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__pagination-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: $pad-large 0;
|
||||
}
|
||||
|
||||
&__file-uploader {
|
||||
margin-top: $pad-xxlarge;
|
||||
}
|
||||
|
||||
&__labels-list {
|
||||
border-radius: 6px;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
|
||||
&--label {
|
||||
display: flex;
|
||||
height: 41px;
|
||||
padding: 0 $pad-large;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
gap: $pad-small;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
&__list {
|
||||
.list-item__label-count {
|
||||
|
|
@ -84,99 +30,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-profile {
|
||||
&__card--content-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-medium;
|
||||
padding: 28.5px 0;
|
||||
}
|
||||
|
||||
&__profile-graphic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
|
||||
&--message {
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: $pad-medium;
|
||||
}
|
||||
|
||||
&__target {
|
||||
margin: $pad-large 0 $pad-small 0;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: $pad-medium 0;
|
||||
}
|
||||
|
||||
&__no-labels {
|
||||
display: flex;
|
||||
height: 187px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
|
||||
&__checkboxes {
|
||||
display: flex;
|
||||
max-height: 187px;
|
||||
flex-direction: column;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
overflow-y: auto;
|
||||
|
||||
.loading-spinner {
|
||||
margin: 69.5px auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
width: 100%;
|
||||
padding: $pad-small $pad-medium;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
}
|
||||
|
||||
.form-field--checkbox {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label-name {
|
||||
padding-left: $pad-large;
|
||||
}
|
||||
|
||||
.fleet-checkbox {
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__label {
|
||||
width: 490px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,35 +6,7 @@ import InfoBanner from "components/InfoBanner";
|
|||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import Icon from "components/Icon";
|
||||
|
||||
interface IModalDescriptionProps {
|
||||
baseClass: string;
|
||||
profileName: string;
|
||||
targetType: "includeAll" | "excludeAny";
|
||||
}
|
||||
|
||||
const ModalDescription = ({
|
||||
baseClass,
|
||||
profileName,
|
||||
targetType,
|
||||
}: IModalDescriptionProps) => {
|
||||
const targetTypeText =
|
||||
targetType === "includeAll" ? (
|
||||
<>
|
||||
have <b>all</b>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
don't have <b>any</b>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__description`}>
|
||||
<b>{profileName}</b> profile only applies to hosts that {targetTypeText}{" "}
|
||||
of these labels:
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const baseClass = "profile-labels-modal";
|
||||
|
||||
const BrokenLabelWarning = () => (
|
||||
<InfoBanner color="yellow">
|
||||
|
|
@ -51,16 +23,10 @@ const BrokenLabelWarning = () => (
|
|||
</InfoBanner>
|
||||
);
|
||||
|
||||
const LabelsList = ({
|
||||
baseClass,
|
||||
labels,
|
||||
}: {
|
||||
baseClass: string;
|
||||
labels: IProfileLabel[];
|
||||
}) => (
|
||||
<div className={`${baseClass}__labels-list`}>
|
||||
const LabelsList = ({ labels }: { labels: IProfileLabel[] }) => (
|
||||
<ul className={`${baseClass}__labels-list`}>
|
||||
{labels.map((label) => (
|
||||
<div key={label.name} className={`${baseClass}__labels-list--label`}>
|
||||
<li key={label.name} className={`${baseClass}__labels-list--label`}>
|
||||
{label.name}
|
||||
{label.broken && (
|
||||
<span className={`${baseClass}__labels-list--label warning`}>
|
||||
|
|
@ -68,19 +34,17 @@ const LabelsList = ({
|
|||
Label deleted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
);
|
||||
|
||||
interface IProfileLabelsModalProps {
|
||||
baseClass: string;
|
||||
profile: IMdmProfile | null;
|
||||
setModalData: React.Dispatch<React.SetStateAction<IMdmProfile | null>>;
|
||||
}
|
||||
|
||||
const ProfileLabelsModal = ({
|
||||
baseClass,
|
||||
profile,
|
||||
setModalData,
|
||||
}: IProfileLabelsModalProps) => {
|
||||
|
|
@ -88,30 +52,53 @@ const ProfileLabelsModal = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
const { name, labels_include_all, labels_exclude_any } = profile;
|
||||
const labels = labels_include_all || labels_exclude_any;
|
||||
const {
|
||||
name,
|
||||
labels_include_all,
|
||||
labels_include_any,
|
||||
labels_exclude_any,
|
||||
} = profile;
|
||||
const labels = labels_include_all || labels_include_any || labels_exclude_any;
|
||||
|
||||
if (!labels?.length) {
|
||||
// caller ensures this never happens
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderlabelDescription = () => {
|
||||
let targetTypeText = <></>;
|
||||
if (labels_include_all) {
|
||||
targetTypeText = <b>have all</b>;
|
||||
} else if (labels_include_any) {
|
||||
targetTypeText = <b>have all</b>;
|
||||
} else {
|
||||
targetTypeText = <b>don't have any</b>;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={`${baseClass}__description`}>
|
||||
<b>{name}</b> profile only applies to hosts that {targetTypeText} of
|
||||
these labels:
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="Custom target" onExit={() => setModalData(null)}>
|
||||
<div className={`${baseClass}__modal-content-wrap`}>
|
||||
<Modal
|
||||
className={baseClass}
|
||||
title="Custom target"
|
||||
onExit={() => setModalData(null)}
|
||||
>
|
||||
<>
|
||||
{labels.some((label) => label.broken) && <BrokenLabelWarning />}
|
||||
<ModalDescription
|
||||
baseClass={baseClass}
|
||||
profileName={name}
|
||||
targetType={labels_include_all ? "includeAll" : "excludeAny"}
|
||||
/>
|
||||
<LabelsList baseClass={baseClass} labels={labels} />
|
||||
<>{renderlabelDescription()}</>
|
||||
<LabelsList labels={labels} />
|
||||
<div className="modal-cta-wrap">
|
||||
<Button variant="brand" onClick={() => setModalData(null)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
.profile-labels-modal {
|
||||
&__description {
|
||||
font-size: $x-small;
|
||||
margin: $pad-large 0 $pad-medium;
|
||||
}
|
||||
|
||||
&__labels-list {
|
||||
border-radius: 6px;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&--label {
|
||||
display: flex;
|
||||
height: 41px;
|
||||
padding: 0 $pad-large;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
gap: $pad-small;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ const ProfileListItem = ({
|
|||
const {
|
||||
created_at,
|
||||
labels_include_all,
|
||||
labels_include_any,
|
||||
labels_exclude_any,
|
||||
name,
|
||||
platform,
|
||||
|
|
@ -102,7 +103,7 @@ const ProfileListItem = ({
|
|||
FileSaver.saveAs(file);
|
||||
};
|
||||
|
||||
const labels = labels_include_all || labels_exclude_any;
|
||||
const labels = labels_include_all || labels_include_any || labels_exclude_any;
|
||||
|
||||
const renderLabelInfo = () => {
|
||||
if (!isPremium || labels === undefined || labels.length === 0) {
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ import React from "react";
|
|||
|
||||
import Card from "components/Card";
|
||||
import Button from "components/buttons/Button";
|
||||
import ProfileGraphic from "./AddProfileGraphic";
|
||||
import ProfileGraphic from "../AddProfileGraphic";
|
||||
|
||||
const AddProfileCard = ({
|
||||
baseClass,
|
||||
setShowModal,
|
||||
}: {
|
||||
baseClass: string;
|
||||
const baseClass = "add-profile-card";
|
||||
|
||||
interface IAddProfileCardProps {
|
||||
setShowModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => (
|
||||
<Card color="gray" className={`${baseClass}__card`}>
|
||||
}
|
||||
|
||||
const AddProfileCard = ({ setShowModal }: IAddProfileCardProps) => (
|
||||
<Card color="gray" className={baseClass}>
|
||||
<div className={`${baseClass}__card--content-wrap`}>
|
||||
<ProfileGraphic baseClass={baseClass} showMessage />
|
||||
<Button
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.add-profile-card {
|
||||
&__card--content-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-medium;
|
||||
padding: 28.5px 0;
|
||||
}
|
||||
|
||||
&__profile-graphic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
|
||||
&--message {
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AddProfileCard";
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
CUSTOM_TARGET_OPTIONS,
|
||||
CustomTargetOption,
|
||||
generateLabelKey,
|
||||
getDescriptionText,
|
||||
listNamesFromSelectedLabels,
|
||||
} from "./helpers";
|
||||
|
||||
|
|
@ -152,18 +153,6 @@ const LabelChooser = ({
|
|||
[setSelectedLabels]
|
||||
);
|
||||
|
||||
const descriptionText =
|
||||
customTargetOption === "labelsIncludeAll" ? (
|
||||
<>
|
||||
Profile will only be applied to hosts that have <b>all</b> these labels:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Profile will be applied to hosts that don't have <b>any</b> of
|
||||
these labels:{" "}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderLabels = () => {
|
||||
if (isLoading) {
|
||||
return <Spinner centered={false} />;
|
||||
|
|
@ -206,7 +195,9 @@ const LabelChooser = ({
|
|||
searchable={false}
|
||||
onChange={onSelectCustomTargetOption}
|
||||
/>
|
||||
<div className={`${baseClass}__description`}>{descriptionText}</div>
|
||||
<div className={`${baseClass}__description`}>
|
||||
{getDescriptionText(customTargetOption)}
|
||||
</div>
|
||||
<div className={`${baseClass}__checkboxes`}>{renderLabels()}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,11 +5,22 @@ import { IDropdownOption } from "interfaces/dropdownOption";
|
|||
export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
|
||||
{
|
||||
value: "labelsIncludeAll",
|
||||
label: "Include all ",
|
||||
label: "Include all",
|
||||
helpText: (
|
||||
<>
|
||||
Profile will only be applied to hosts that have <b>all</b> of these
|
||||
labels{" "}
|
||||
Profile will only be applied to hosts that <b>have all</b> of these
|
||||
labels.
|
||||
</>
|
||||
),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: "labelsIncludeAny",
|
||||
label: "Include any",
|
||||
helpText: (
|
||||
<>
|
||||
Profile will only be applied to hosts that <b>have any</b> of these
|
||||
labels.
|
||||
</>
|
||||
),
|
||||
disabled: false,
|
||||
|
|
@ -19,8 +30,8 @@ export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
|
|||
label: "Exclude any",
|
||||
helpText: (
|
||||
<>
|
||||
Profile will be applied to hosts that don't have <b>any</b> of
|
||||
these labels{" "}
|
||||
Profile will only be applied to hosts that <b>don't have any</b> of
|
||||
these labels.
|
||||
</>
|
||||
),
|
||||
disabled: false,
|
||||
|
|
@ -36,7 +47,10 @@ export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
|
|||
}, [] as string[]);
|
||||
};
|
||||
|
||||
export type CustomTargetOption = "labelsIncludeAll" | "labelsExcludeAny";
|
||||
export type CustomTargetOption =
|
||||
| "labelsIncludeAll"
|
||||
| "labelsIncldeAny"
|
||||
| "labelsExcludeAny";
|
||||
|
||||
export const generateLabelKey = (
|
||||
target: string,
|
||||
|
|
@ -51,3 +65,8 @@ export const generateLabelKey = (
|
|||
[customTargetOption]: listNamesFromSelectedLabels(selectedLabels),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDescriptionText = (value: string) => {
|
||||
return CUSTOM_TARGET_OPTIONS.find((option) => option.value === value)
|
||||
?.helpText;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export interface IUploadProfileApiParams {
|
|||
file: File;
|
||||
teamId?: number;
|
||||
labelsIncludeAll?: string[];
|
||||
labelsIncludeAny?: string[];
|
||||
labelsExcludeAny?: string[];
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +133,7 @@ const mdmService = {
|
|||
file,
|
||||
teamId,
|
||||
labelsIncludeAll,
|
||||
labelsIncludeAny,
|
||||
labelsExcludeAny,
|
||||
}: IUploadProfileApiParams) => {
|
||||
const { MDM_PROFILES } = endpoints;
|
||||
|
|
@ -143,11 +145,18 @@ const mdmService = {
|
|||
formData.append("team_id", teamId.toString());
|
||||
}
|
||||
|
||||
if (labelsIncludeAll || labelsExcludeAny) {
|
||||
const labels = labelsIncludeAll || labelsExcludeAny;
|
||||
const labelKey = labelsIncludeAll
|
||||
? "labels_include_all"
|
||||
: "labels_exclude_any";
|
||||
if (labelsIncludeAll || labelsIncludeAny || labelsExcludeAny) {
|
||||
const labels = labelsIncludeAll || labelsIncludeAny || labelsExcludeAny;
|
||||
|
||||
let labelKey = "";
|
||||
if (labelsIncludeAll) {
|
||||
labelKey = "labels_include_all";
|
||||
} else if (labelsIncludeAny) {
|
||||
labelKey = "labels_include_any";
|
||||
} else {
|
||||
labelKey = "labels_exclude_any";
|
||||
}
|
||||
|
||||
labels?.forEach((label) => {
|
||||
formData.append(labelKey, label);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue