UI: Labels by IdP (#30368)

This commit is contained in:
jacobshandling 2025-06-30 10:05:03 -07:00 committed by GitHub
parent 1227900d7b
commit 48ea14abbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 700 additions and 260 deletions

1
changes/23899-IdP-labels Normal file
View file

@ -0,0 +1 @@
- Add support for IdP-based labels

View file

@ -1,5 +1,5 @@
import { ILabel } from "interfaces/label";
import { IGetLabelResonse } from "services/entities/labels";
import { IGetLabelResponse } from "services/entities/labels";
const DEFAULT_LABEL_MOCK: ILabel = {
created_at: "2024-04-12T13:32:00Z",
@ -14,18 +14,22 @@ const DEFAULT_LABEL_MOCK: ILabel = {
display_text: "test macsss",
count: 0,
host_ids: null,
criteria: {
vital: "end_user_idp_department",
value: " IT admins",
},
};
export const createMockLabel = (overrides?: Partial<ILabel>): ILabel => {
return { ...DEFAULT_LABEL_MOCK, ...overrides };
};
const DEFAULT_GET_LABEL_RESPONSE_MOCK: IGetLabelResonse = {
const DEFAULT_GET_LABEL_RESPONSE_MOCK: IGetLabelResponse = {
label: createMockLabel(),
};
export const createMockGetLabelResponse = (
overrides?: Partial<IGetLabelResonse>
): IGetLabelResonse => {
overrides?: Partial<IGetLabelResponse>
): IGetLabelResponse => {
return { ...DEFAULT_GET_LABEL_RESPONSE_MOCK, ...overrides };
};

View file

@ -27,14 +27,14 @@ import { capitalize } from "lodash";
import permissions from "utilities/permissions";
import PageError from "components/DataError";
import TargetsInput from "components/LiveQuery/TargetsInput";
import TargetsInput from "components/TargetsInput";
import { generateTableHeaders } from "components/TargetsInput/TargetsInputHostsTableConfig";
import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import SearchField from "components/forms/fields/SearchField";
import RevealButton from "components/buttons/RevealButton";
import TargetPillSelector from "./TargetChipSelector";
import { generateTableHeaders } from "./TargetsInput/TargetsInputHostsTableConfig";
interface ISelectTargetsProps {
baseClass: string;

View file

@ -15,7 +15,28 @@ export default PropTypes.shape({
});
export type LabelType = "regular" | "builtin";
export type LabelMembershipType = "dynamic" | "manual";
export type LabelMembershipType = "dynamic" | "manual" | "host_vitals";
export type LabelHostVitalsCriterion =
| "end_user_idp_group"
| "end_user_idp_department"; // for now, may expand to be configurable
export type LabelLeafCriterion = {
vital: LabelHostVitalsCriterion;
value: string; // from user input
};
type LabelAndCriterion = {
and: LabelHostVitalsCriteria[];
};
type LabelOrCriterion = {
or: LabelHostVitalsCriteria[];
};
export type LabelHostVitalsCriteria =
| LabelLeafCriterion
| LabelAndCriterion
| LabelOrCriterion;
export interface ILabelSummary {
id: number;
@ -43,17 +64,25 @@ export interface ILabel extends ILabelSummary {
created_at: string;
updated_at: string;
uuid?: string;
query: string;
label_membership_type: LabelMembershipType;
host_count?: number; // returned for built-in labels but not custom labels
display_text: string;
count: number; // seems to be a repeat of hosts_count issue #1618
host_ids: number[] | null;
type?: "custom" | "platform" | "status" | "all";
slug?: string; // e.g., "labels/13" | "online"
target_type?: string; // e.g., "labels"
platform: string;
author_id?: number;
label_membership_type: LabelMembershipType;
// dynamic-specific
query: string; // does return '""' for other types
platform: string; // does return '""' for other types
// host_vitals-specific
criteria: LabelHostVitalsCriteria | null;
// manual-specific
host_ids: number[] | null;
}
// corresponding to fleet>server>fleet>labels.go>LabelSpec

View file

@ -115,7 +115,7 @@ const FailedEndUserInfoCard = ({
const IdentityProviders = () => {
const { isPremiumTier } = useContext(AppContext);
const { data, isLoading, isError } = useQuery(
const { data: scimIdPDetails, isLoading, isError } = useQuery(
["scim_details"],
() => idpAPI.getSCIMDetails(),
{
@ -137,19 +137,21 @@ const IdentityProviders = () => {
return <Spinner />;
}
if (!data) return null;
if (!scimIdPDetails) return null;
if (data.last_request === null) {
if (scimIdPDetails.last_request === null) {
return <AddEndUserInfoCard />;
} else if (data.last_request.status === "success") {
} else if (scimIdPDetails.last_request.status === "success") {
return (
<ReceivedEndUserInfoCard receivedAt={data.last_request.requested_at} />
<ReceivedEndUserInfoCard
receivedAt={scimIdPDetails.last_request.requested_at}
/>
);
} else if (data.last_request.status === "error") {
} else if (scimIdPDetails.last_request.status === "error") {
return (
<FailedEndUserInfoCard
receivedAt={data.last_request.requested_at}
details={data.last_request.details}
receivedAt={scimIdPDetails.last_request.requested_at}
details={scimIdPDetails.last_request.details}
/>
);
}

View file

@ -169,7 +169,12 @@ const HostsFilterBlock = ({
const renderLabelFilterPill = () => {
if (selectedLabel) {
const { description, display_text, label_type } = selectedLabel;
const {
description,
display_text,
label_type,
label_membership_type,
} = selectedLabel;
const pillLabel =
(isPlatformLabelNameFromAPI(display_text) &&
PLATFORM_LABEL_DISPLAY_NAMES[display_text]) ||
@ -196,13 +201,18 @@ const HostsFilterBlock = ({
!isOnlyObserver &&
(isOnGlobalTeam || currentUser?.id === selectedLabel.author_id) && (
<>
<Button
className={`${baseClass}__action-btn`}
onClick={onClickEditLabel}
variant="icon"
>
<Icon name="pencil" size="small" />
</Button>
{
// TODO - remove condition if/when can edit host_vitals labels
label_membership_type !== "host_vitals" && (
<Button
className={`${baseClass}__action-btn`}
onClick={onClickEditLabel}
variant="icon"
>
<Icon name="pencil" size="small" />
</Button>
)
}
<Button
className={`${baseClass}__action-btn`}
onClick={onClickDeleteLabel}

View file

@ -6,7 +6,7 @@ import { AxiosError } from "axios";
import PATHS from "router/paths";
import labelsAPI, {
IGetHostsInLabelResponse,
IGetLabelResonse,
IGetLabelResponse,
} from "services/entities/labels";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { ILabel } from "interfaces/label";
@ -42,12 +42,18 @@ const EditLabelPage = ({ routeParams, router }: IEditLabelPageProps) => {
data: label,
isLoading: isLoadingLabel,
isError: isErrorLabel,
} = useQuery<IGetLabelResonse, AxiosError, ILabel>(
} = useQuery<IGetLabelResponse, AxiosError, ILabel>(
["label", labelId],
() => labelsAPI.getLabel(labelId),
{
...DEFAULT_USE_QUERY_OPTIONS,
select: (data) => data.label,
onSuccess: (data) => {
// can't edit host_vitals labels yet
if (data.label_membership_type === "host_vitals") {
router.replace(PATHS.MANAGE_HOSTS_LABEL(data.id));
}
},
}
);

View file

@ -1,80 +0,0 @@
import React, { useContext, useCallback } from "react";
import { RouteComponentProps } from "react-router";
import PATHS from "router/paths";
import labelsAPI from "services/entities/labels";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { getPathWithQueryParams } from "utilities/url";
import { IApiError } from "interfaces/errors";
import { API_ALL_TEAMS_ID, APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import DynamicLabelForm from "pages/labels/components/DynamicLabelForm";
import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm";
import { DUPLICATE_ENTRY_ERROR } from "../ManualLabel/ManualLabel";
const baseClass = "dynamic-label";
const DEFAULT_QUERY = "SELECT 1 FROM os_version WHERE major >= 13;";
type IDynamicLabelProps = RouteComponentProps<never, never> & {
showOpenSidebarButton: boolean;
onOpenSidebar: () => void;
onOsqueryTableSelect: (tableName: string) => void;
};
const DynamicLabel = ({
showOpenSidebarButton,
router,
onOpenSidebar,
onOsqueryTableSelect,
}: IDynamicLabelProps) => {
const { currentTeam } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const onSaveNewLabel = useCallback(
(formData: IDynamicLabelFormData) => {
labelsAPI
.create(formData)
.then((res) => {
router.push(
getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(res.label.id), {
team_id:
currentTeam?.id === APP_CONTEXT_ALL_TEAMS_ID
? API_ALL_TEAMS_ID
: currentTeam?.id,
})
);
renderFlash("success", "Label added successfully.");
})
.catch((error: { data: IApiError }) => {
renderFlash(
"error",
error.data.errors[0].reason.includes("Duplicate entry")
? DUPLICATE_ENTRY_ERROR
: "Couldn't add label. Please try again."
);
});
},
[renderFlash, router]
);
const onCancelLabel = () => {
router.goBack();
};
return (
<div className={baseClass}>
<DynamicLabelForm
defaultQuery={DEFAULT_QUERY}
showOpenSidebarButton={showOpenSidebarButton}
onOpenSidebar={onOpenSidebar}
onOsqueryTableSelect={onOsqueryTableSelect}
onSave={onSaveNewLabel}
onCancel={onCancelLabel}
/>
</div>
);
};
export default DynamicLabel;

View file

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

View file

@ -1,64 +0,0 @@
import React, { useCallback, useContext } from "react";
import { RouteComponentProps } from "react-router";
import PATHS from "router/paths";
import labelsAPI from "services/entities/labels";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { getPathWithQueryParams } from "utilities/url";
import { IApiError } from "interfaces/errors";
import { API_ALL_TEAMS_ID, APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import ManualLabelForm from "pages/labels/components/ManualLabelForm";
import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm";
const baseClass = "manual-label";
export const DUPLICATE_ENTRY_ERROR =
"Couldn't add. A label with this name already exists.";
type IManualLabelProps = RouteComponentProps<never, never>;
const ManualLabel = ({ router }: IManualLabelProps) => {
const { currentTeam } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const onSaveNewLabel = useCallback(
(formData: IManualLabelFormData) => {
labelsAPI
.create(formData)
.then((res) => {
router.push(
getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(res.label.id), {
team_id:
currentTeam?.id === APP_CONTEXT_ALL_TEAMS_ID
? API_ALL_TEAMS_ID
: currentTeam?.id,
})
);
renderFlash("success", "Label added successfully.");
})
.catch((error: { data: IApiError }) => {
renderFlash(
"error",
error.data.errors[0].reason.includes("Duplicate entry")
? DUPLICATE_ENTRY_ERROR
: "Couldn't add label. Please try again."
);
});
},
[renderFlash, router]
);
const onCancelLabel = () => {
router.goBack();
};
return (
<div className={baseClass}>
<ManualLabelForm onSave={onSaveNewLabel} onCancel={onCancelLabel} />
</div>
);
};
export default ManualLabel;

View file

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

View file

@ -1,63 +1,131 @@
import React, { useCallback, useContext, useState } from "react";
import { Tab, TabList, Tabs } from "react-tabs";
import React, { useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useDebouncedCallback } from "use-debounce";
import { IAceEditor } from "react-ace/lib/types";
import { Row } from "react-table";
import PATHS from "router/paths";
import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets";
import idpAPI from "services/entities/idp";
import labelsAPI from "services/entities/labels";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
// TODO - move this table config near here once expanded this logic to encompass editing and
// therefore not longer needed anywhere else
import { generateTableHeaders } from "pages/labels/components/ManualLabelForm/LabelHostTargetTableConfig";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
import { QueryContext } from "context/query";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useToggleSidePanel from "hooks/useToggleSidePanel";
import MainContent from "components/MainContent";
import SidePanelContent from "components/SidePanelContent";
import TabNav from "components/TabNav";
import TabText from "components/TabText";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Button from "components/buttons/Button";
import PATHS from "router/paths";
import { RouteComponentProps } from "react-router";
import {
LabelHostVitalsCriterion,
LabelMembershipType,
} from "interfaces/label";
import { IHost } from "interfaces/host";
import { IFormField } from "interfaces/form_field";
import SQLEditor from "components/SQLEditor";
import Icon from "components/Icon";
import TargetsInput from "components/TargetsInput";
import Radio from "components/forms/fields/Radio";
interface ILabelSubNavItem {
name: string;
pathname: string;
}
import PlatformField from "../components/PlatformField";
const labelSubNav: ILabelSubNavItem[] = [
{
name: "Dynamic",
pathname: PATHS.LABEL_NEW_DYNAMIC,
},
{
name: "Manual",
pathname: PATHS.LABEL_NEW_MANUAL,
},
const availableCriteria: {
label: string;
value: LabelHostVitalsCriterion;
}[] = [
{ label: "Identity provider (IdP) group", value: "end_user_idp_group" },
{ label: "IdP department", value: "end_user_idp_department" },
];
const getTabIndex = (path: string): number => {
return labelSubNav.findIndex((navItem) => {
// tab stays highlighted for paths that start with same pathname
return path.startsWith(navItem.pathname);
});
};
const baseClass = "new-label-page";
interface INewLabelPageProps extends RouteComponentProps<never, never> {
children: JSX.Element;
export const LABEL_TARGET_HOSTS_INPUT_LABEL = "Select hosts";
const LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER =
"Search name, hostname, or serial number";
const DEBOUNCE_DELAY = 500;
interface ITargetsQueryKey {
scope: string;
query?: string | null;
excludedHostIds?: number[];
}
const NewLabelPage = ({ router, location, children }: INewLabelPageProps) => {
export interface INewLabelFormData {
name: string;
description: string; // optional
type: LabelMembershipType;
// dynamic
labelQuery: string;
platform: string;
// host vitals
vital: LabelHostVitalsCriterion; // TODO - make use of recursive `LabelHostVitalsCriteria` type in future iterations to support logical combinations of different criteria
vitalValue: string;
// manual
targetedHosts: IHost[];
}
interface INewLabelFormErrors {
name?: string | null;
labelQuery?: string | null;
criteria?: string | null;
}
const validate = (newData: INewLabelFormData) => {
const errors: INewLabelFormErrors = {};
const { name, type, labelQuery, vitalValue } = newData;
if (!name) {
errors.name = "Label name must be present";
}
if (type === "dynamic") {
if (!labelQuery) {
errors.labelQuery = "Query text must be present";
}
} else if (type === "host_vitals") {
if (!vitalValue) {
errors.criteria = "Label criteria must be completed";
}
}
return errors;
};
const DEFAULT_DYNAMIC_QUERY = "SELECT 1 FROM os_version WHERE major >= 13;";
const NewLabelPage = ({
router,
location,
}: RouteComponentProps<never, never>) => {
// page-level state
const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext(
QueryContext
);
const { isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const { isSidePanelOpen, setSidePanelOpen } = useToggleSidePanel(true);
const [showOpenSidebarButton, setShowOpenSidebarButton] = useState(false);
const isDynamicLabel = location.pathname.includes("dynamic");
const navigateToNav = useCallback(
(i: number): void => {
router.replace(labelSubNav[i].pathname);
},
[router]
);
// page-level handlers
const onCloseSidebar = () => {
setSidePanelOpen(false);
setShowOpenSidebarButton(true);
@ -72,37 +140,430 @@ const NewLabelPage = ({ router, location, children }: INewLabelPageProps) => {
setSelectedOsqueryTable(tableName);
};
// form state
const [isUpdating, setIsUpdating] = useState(false);
const [formData, setFormData] = useState<INewLabelFormData>({
name: "",
description: "",
type: "dynamic", // default type
// dynamic-specific
labelQuery: DEFAULT_DYNAMIC_QUERY,
platform: "",
// host_vitals-specific
vital: "end_user_idp_group",
vitalValue: "",
// manual-specific
targetedHosts: [],
});
const {
name,
description,
type,
labelQuery,
platform,
vital,
vitalValue,
targetedHosts,
} = formData;
const [formErrors, setFormErrors] = useState<INewLabelFormErrors>({});
const [targetsSearchQuery, setTargetsSearchQuery] = useState("");
const [
debouncedTargetsSearchQuery,
setDebouncedTargetsSearchQuery,
] = useState("");
const [isDebouncingTargetsSearch, setIsDebouncingTargetsSearch] = useState(
false
);
// "manual" label target search logic
const debounceSearch = useDebouncedCallback(
(search: string) => {
setDebouncedTargetsSearchQuery(search);
setIsDebouncingTargetsSearch(false);
},
DEBOUNCE_DELAY,
{ trailing: true }
);
useEffect(() => {
setIsDebouncingTargetsSearch(true);
debounceSearch(targetsSearchQuery);
}, [debounceSearch, targetsSearchQuery]);
const {
data: targetsSearchResults,
isLoading: isLoadingTargetsSearchResults,
isError: isErrorTargetsSearchResults,
} = useQuery<ITargetsSearchResponse, Error, IHost[], ITargetsQueryKey[]>(
[
{
scope: "labels-targets-search",
query: debouncedTargetsSearchQuery,
excludedHostIds: targetedHosts.map((host) => host.id),
},
],
({ queryKey }) => {
const { query, excludedHostIds } = queryKey[0];
return targetsAPI.search({
query: query ?? "",
excluded_host_ids: excludedHostIds ?? null,
});
},
{
select: (data) => data.hosts,
enabled: type === "manual" && !!targetsSearchQuery,
}
);
const { data: scimIdPDetails } = useQuery(
["scim_details"],
() => idpAPI.getSCIMDetails(),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isPremiumTier,
}
);
const idpConfigured = !!scimIdPDetails?.last_request?.requested_at;
let hostVitalsTooltipContent: React.ReactNode;
if (!isPremiumTier) {
hostVitalsTooltipContent = (
<>
Currently, host vitals labels are based on
<br />
identity provider (IdP) groups or departments.
<br />
IdP integration available in Fleet Premium.
</>
);
} else if (!idpConfigured) {
hostVitalsTooltipContent = (
<>
Currently, host vitals labels are based on
<br />
identity provider (IdP) groups or departments.
<br />
IdP has not been configured in integration settings.
</>
);
}
useEffect(() => {
if (location.pathname.includes("dynamic")) {
router.replace(PATHS.NEW_LABEL);
}
if (location.pathname.includes("manual")) {
setFormData((prevData) => ({
...prevData,
type: "manual",
}));
router.replace(PATHS.NEW_LABEL);
}
}, [location.pathname, router]);
// form handlers
const onInputChange = ({ name: fieldName, value }: IFormField) => {
const newFormData = { ...formData, [fieldName]: value };
setFormData(newFormData);
const newErrs = validate(newFormData);
// only set errors that are updates of existing errors
// new errors are only set onBlur or submit
const errsToSet: Record<string, string> = {};
Object.keys(formErrors).forEach((k) => {
// @ts-ignore
if (newErrs[k]) {
// @ts-ignore
errsToSet[k] = newErrs[k];
}
});
setFormErrors(errsToSet);
};
const onTypeChange = (value: string): void => {
const newFormData = {
...formData,
type: value as LabelMembershipType, // reconcile type differences between form data and radio component handler
};
setFormData(newFormData);
const newErrs = validate(newFormData);
const errsToSet: Record<string, string> = {};
Object.keys(formErrors).forEach((k) => {
// @ts-ignore
if (newErrs[k]) {
// @ts-ignore
errsToSet[k] = newErrs[k];
}
});
setFormErrors(errsToSet);
};
const onInputBlur = () => {
setFormErrors(validate(formData));
};
const onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) {
setFormErrors(errs);
return;
}
setIsUpdating(true);
try {
const res = await labelsAPI.create(formData);
router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id));
renderFlash("success", "Label added successfully.");
} catch {
renderFlash("error", "Couldn't add label. Please try again.");
}
setIsUpdating(false);
};
const debounceValidateSQL = useDebouncedCallback((queryString: string) => {
const { error } = validateQuery(queryString);
return error || null;
}, 500);
const onQueryChange = (newQuery: string) => {
setFormData((prevData) => ({
...prevData,
labelQuery: newQuery,
}));
debounceValidateSQL(newQuery);
};
// form rendering helpers
const onLoadSQLEditor = (editor: IAceEditor) => {
editor.setOptions({
enableLinking: true,
enableMultiselect: false, // Disables command + click creating multiple cursors
});
// @ts-expect-error
// the string "linkClick" is not officially in the lib but we need it
editor.on("linkClick", (data) => {
const { type: type_, value } = data.token;
if (type_ === "osquery-token" && onOsqueryTableSelect) {
return onOsqueryTableSelect(value);
}
return false;
});
};
const onChangeSearchQuery = (value: string) => {
setTargetsSearchQuery(value);
};
const onHostSelect = (row: Row<IHost>) => {
setFormData((prevData) => ({
...prevData,
targetedHosts: targetedHosts.concat(row.original),
}));
setTargetsSearchQuery("");
};
const onHostRemove = (row: Row<IHost>) => {
setFormData((prevData) => ({
...prevData,
targetedHosts: targetedHosts.filter((h) => h.id !== row.original.id),
}));
};
const resultsTableConfig = generateTableHeaders();
const selectedHostsTableConfig = generateTableHeaders(onHostRemove);
const renderVariableFields = () => {
switch (type) {
case "dynamic":
return (
<>
<SQLEditor
error={formErrors.labelQuery}
name="query"
onChange={onQueryChange}
onBlur={onInputBlur}
value={labelQuery}
label="Query"
labelActionComponent={
showOpenSidebarButton ? (
<Button variant="text-icon" onClick={onOpenSidebar}>
Schema
<Icon name="info" size="small" />
</Button>
) : null
}
// readOnly={isEditing} TODO when extending to handle edits
onLoad={onLoadSQLEditor}
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
// helpText={isEditing ? IMMUTABLE_QUERY_HELP_TEXT : ""} TODO when extending to handle edits
wrapEnabled
/>
<PlatformField
platform={platform}
// isEditing={isEditing} TODO when extending to handle edits
// onChange={onInputChange} TODO - once this form covers edits, can use the commmon
// `onInputChange` along with updating PlatformField's Dropdown to `parseTarget`
onChange={(newPlatform) => {
setFormData((prevData) => ({
...prevData,
platform: newPlatform,
}));
}}
/>
</>
);
case "host_vitals":
return (
<div className={`${baseClass}__host_vitals-fields`}>
<Dropdown
label="Label criteria"
name="vital"
onChange={onInputChange}
parseTarget
value={vital}
error={formErrors.criteria}
options={availableCriteria}
classname={`${baseClass}__criteria-dropdown`}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--criteria`}
helpText="Currently, label criteria can be IdP group or department."
/>
<p>is equal to</p>
<InputField
error={formErrors.criteria}
name="vitalValue"
onChange={onInputChange}
onBlur={onInputBlur}
value={vitalValue}
inputClassName={`${baseClass}__vital-value`}
placeholder={
vital === "end_user_idp_group" ? "IT admins" : "Engineering"
}
parseTarget
/>
</div>
);
case "manual":
return (
<TargetsInput
label={LABEL_TARGET_HOSTS_INPUT_LABEL}
placeholder={LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER}
searchText={targetsSearchQuery}
searchResultsTableConfig={resultsTableConfig}
selectedHostsTableConifg={selectedHostsTableConfig}
isTargetsLoading={
isLoadingTargetsSearchResults || isDebouncingTargetsSearch
}
hasFetchError={isErrorTargetsSearchResults}
searchResults={targetsSearchResults ?? []}
targetedHosts={targetedHosts}
setSearchText={onChangeSearchQuery}
handleRowSelect={onHostSelect}
/>
);
default:
return null;
}
};
const renderLabelForm = () => (
<form className={`${baseClass}__label-form`} onSubmit={onSubmit}>
<InputField
error={formErrors.name}
name="name"
onChange={onInputChange}
onBlur={onInputBlur}
value={name}
inputClassName={`${baseClass}__label-name`}
label="Name"
placeholder="Label name"
parseTarget
/>
<InputField
name="description"
onChange={onInputChange}
onBlur={onInputBlur}
value={description}
inputClassName={`${baseClass}__label-description`}
label="Description"
type="textarea"
placeholder="Label description (optional)"
parseTarget
/>
<div className="form-field type-field">
<div className="form-field__label">Type</div>
<Radio
className={`${baseClass}__radio-input`}
label="Dynamic"
id="dynamic"
checked={type === "dynamic"}
value="dynamic"
name="label-type"
onChange={onTypeChange}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Host vitals"
id="host_vitals"
checked={type === "host_vitals"}
value="host_vitals"
name="label-type"
onChange={onTypeChange}
tooltip={hostVitalsTooltipContent}
disabled={!!hostVitalsTooltipContent}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Manual"
id="manual"
checked={type === "manual"}
value="manual"
name="label-type"
onChange={onTypeChange}
/>
</div>
{renderVariableFields()}
<div className="button-wrap">
<Button
onClick={() => {
router.goBack();
}}
variant="inverse"
disabled={isUpdating}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isUpdating}
disabled={isUpdating || !!Object.entries(formErrors).length}
>
Save
</Button>
</div>
</form>
);
return (
<>
<MainContent className={baseClass}>
<h1>Add label</h1>
<p className={`${baseClass}__page-description`}>
Dynamic (smart) labels are assigned to hosts if the query returns
results. Manual labels are assigned to selected hosts.
</p>
<TabNav className={`${baseClass}__new-label-tab-nav`}>
<Tabs
selectedIndex={getTabIndex(location?.pathname || "")}
onSelect={navigateToNav}
>
<TabList>
{labelSubNav.map((navItem) => {
return (
<Tab key={navItem.name} data-text={navItem.name}>
<TabText>{navItem.name}</TabText>
</Tab>
);
})}
</TabList>
</Tabs>
</TabNav>
{React.cloneElement(children, {
showOpenSidebarButton,
onOpenSidebar,
onOsqueryTableSelect,
})}
<div className={`${baseClass}__header`}>
<h1>New label</h1>
<p className={`${baseClass}__page-description`}>
Create a new label for targeting and filtering hosts.
</p>
</div>
{renderLabelForm()}
</MainContent>
{isDynamicLabel && isSidePanelOpen && (
{type === "dynamic" && isSidePanelOpen && (
<SidePanelContent>
<QuerySidePanel
key="query-side-panel"

View file

@ -1,15 +1,62 @@
.new-label-page {
h1 {
&__header {
display: flex;
flex-direction: column;
gap: $pad-smedium;
margin-bottom: $pad-large;
}
&__page-description {
margin: 0;
color: $ui-fleet-black-75;
font-size: $xx-small;
font-style: italic;
}
&__label-name,
&__label-description {
width: 100%;
resize: vertical;
}
&__new-label-tab-nav {
margin-bottom: $pad-xxlarge;
&__label-platform {
font-size: $x-small;
font-weight: $regular;
color: $core-fleet-black;
padding-bottom: $pad-large;
p {
padding-bottom: $pad-small;
margin-top: 0;
margin-bottom: 0;
}
.title {
font-weight: $bold;
}
.help-text {
color: $core-fleet-blue;
padding-top: $pad-small;
}
}
&__host_vitals-fields {
width: auto;
display: flex;
align-items: center;
gap: $pad-medium;
.form-field--dropdown {
margin-right: -14px;
.form-field__help-text {
white-space: nowrap;
}
.Select {
width: 300px;
}
}
p {
white-space: nowrap;
}
dropdown__select--error.Select {
border: initial;
}
}
}

View file

@ -6,7 +6,7 @@ import { useDebouncedCallback } from "use-debounce";
import { IHost } from "interfaces/host";
import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets";
import TargetsInput from "components/LiveQuery/TargetsInput";
import TargetsInput from "components/TargetsInput";
import LabelForm from "../LabelForm";
import { ILabelFormData } from "../LabelForm/LabelForm";

View file

@ -32,8 +32,6 @@ import ForgotPasswordPage from "pages/ForgotPasswordPage";
import GatedLayout from "layouts/GatedLayout";
import HostDetailsPage from "pages/hosts/details/HostDetailsPage";
import NewLabelPage from "pages/labels/NewLabelPage";
import DynamicLabel from "pages/labels/NewLabelPage/DynamicLabel";
import ManualLabel from "pages/labels/NewLabelPage/ManualLabel";
import EditLabelPage from "pages/labels/EditLabelPage";
import LoginPage, { LoginPreviewPage } from "pages/LoginPage";
import LogoutPage from "pages/LogoutPage";
@ -227,11 +225,11 @@ const routes = (
<Redirect from="teams/:team_id/options" to="teams" />
</Route>
<Route path="labels">
<IndexRedirect to="new/dynamic" />
<IndexRedirect to="new" />
<Route path="new" component={NewLabelPage}>
<IndexRedirect to="dynamic" />
<Route path="dynamic" component={DynamicLabel} />
<Route path="manual" component={ManualLabel} />
{/* maintaining previous 2 sub-routes for backward-compatibility of URL routes. NewLabelPage now sets the corresponding label type */}
<Route path="dynamic" component={NewLabelPage} />
<Route path="manual" component={NewLabelPage} />
</Route>
<Route path=":label_id" component={EditLabelPage} />
</Route>

View file

@ -90,8 +90,12 @@ export default {
SOFTWARE_ADD_APP_STORE: `${URL_PREFIX}/software/add/app-store`,
// Label pages
NEW_LABEL: `${URL_PREFIX}/labels/new`,
// deprecated - now handled by `/new` route
LABEL_NEW_DYNAMIC: `${URL_PREFIX}/labels/new/dynamic`,
// deprecated - now handled by `/new` route
LABEL_NEW_MANUAL: `${URL_PREFIX}/labels/new/manual`,
LABEL_EDIT: (labelId: number) => `${URL_PREFIX}/labels/${labelId}`,
EDIT_PACK: (packId: number): string => {
@ -185,7 +189,6 @@ export default {
MANAGE_QUERIES: `${URL_PREFIX}/queries/manage`,
MANAGE_SCHEDULE: `${URL_PREFIX}/schedule/manage`,
MANAGE_POLICIES: `${URL_PREFIX}/policies/manage`,
NEW_LABEL: `${URL_PREFIX}/labels/new`,
NEW_POLICY: `${URL_PREFIX}/policies/new`,
NEW_QUERY: `${URL_PREFIX}/queries/new`,
RESET_PASSWORD: `${URL_PREFIX}/login/reset`,

View file

@ -6,6 +6,7 @@ import { ILabel, ILabelSummary } from "interfaces/label";
import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm";
import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm";
import { IHost } from "interfaces/host";
import { INewLabelFormData } from "pages/labels/NewLabelPage/NewLabelPage";
export interface ILabelsResponse {
labels: ILabel[];
@ -19,7 +20,7 @@ export interface ICreateLabelResponse {
label: ILabel;
}
export type IUpdateLabelResponse = ICreateLabelResponse;
export type IGetLabelResonse = ICreateLabelResponse;
export type IGetLabelResponse = ICreateLabelResponse;
export interface IGetHostsInLabelResponse {
hosts: IHost[];
@ -31,7 +32,7 @@ const isManualLabelFormData = (
return "targetedHosts" in formData;
};
const generateCreateLabelBody = (
const generateUpdateLabelBody = (
formData: IDynamicLabelFormData | IManualLabelFormData
) => {
// we need to prepare the post body for only manual labels.
@ -45,7 +46,34 @@ const generateCreateLabelBody = (
return formData;
};
const generateUpdateLabelBody = generateCreateLabelBody;
const generateCreateLabelBody = (formData: INewLabelFormData) => {
switch (formData.type) {
case "manual":
return {
name: formData.name,
description: formData.description,
host_ids: formData.targetedHosts.map((host) => host.id),
};
case "dynamic":
return {
name: formData.name,
description: formData.description,
query: formData.labelQuery,
platform: formData.platform,
};
case "host_vitals":
return {
name: formData.name,
description: formData.description,
criteria: {
vital: formData.vital,
value: formData.vitalValue,
},
};
default:
throw new Error(`Unknown label type: ${formData.type}`);
}
};
/** gets the custom label and returns them in case-insensitive alphabetical
* ascending order by label name. (e.g. [A, B, C, a, b, c] => [A, a, B, b, C, c])
@ -70,12 +98,9 @@ export const getCustomLabels = <T extends { label_type: string; name: string }>(
};
export default {
create: (
formData: IDynamicLabelFormData | IManualLabelFormData
): Promise<ICreateLabelResponse> => {
create: (formData: INewLabelFormData): Promise<ICreateLabelResponse> => {
const { LABELS } = endpoints;
const postBody = generateCreateLabelBody(formData);
return sendRequest("POST", LABELS, postBody);
return sendRequest("POST", LABELS, generateCreateLabelBody(formData));
},
destroy: (label: ILabel) => {
@ -117,7 +142,7 @@ export default {
return sendRequest("GET", path);
},
getLabel: (labelId: number): Promise<IGetLabelResonse> => {
getLabel: (labelId: number): Promise<IGetLabelResponse> => {
const { LABEL } = endpoints;
return sendRequest("GET", LABEL(labelId));
},