add ability to create manual labels (#18303)

relates to #17031

Adds functionality to create manual labels in fleet.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Added/updated tests
- [x] M0anual QA for all new/changed functionality

---------

Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
This commit is contained in:
Gabriel Hernandez 2024-04-16 17:22:08 +01:00 committed by GitHub
parent de94299b65
commit 2fc4e520b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1607 additions and 726 deletions

View file

@ -0,0 +1 @@
* Updated the `POST /api/v1/fleet/labels` and `PATCH /api/v1/fleet/labels/{id}` endpoints to support creation and update of manual labels.

View file

@ -0,0 +1 @@
- implement manual labels in fleet UI

View file

@ -937,6 +937,37 @@ spec:
query: select 1;
platforms:
- darwin
`
manualLabelSpec = `---
apiVersion: v1
kind: label
spec:
name: manual_label
label_membership_type: manual
hosts:
- host1
platforms:
- darwin
`
emptyManualLabelSpec = `---
apiVersion: v1
kind: label
spec:
name: empty_manual_label
label_membership_type: manual
hosts: []
platforms:
- darwin
`
nohostsManualLabelSpec = `---
apiVersion: v1
kind: label
spec:
name: invalid_nohost_manual_label
label_membership_type: manual
hosts:
platforms:
- darwin
`
packsSpec = `---
apiVersion: v1
@ -1509,6 +1540,37 @@ func TestApplyLabels(t *testing.T) {
require.Len(t, appliedLabels, 1)
assert.Equal(t, "pending_updates", appliedLabels[0].Name)
assert.Equal(t, "select 1;", appliedLabels[0].Query)
appliedLabels = nil
ds.ApplyLabelSpecsFuncInvoked = false
name = writeTmpYml(t, manualLabelSpec)
assert.Equal(t, "[+] applied 1 labels\n", runAppForTest(t, []string{"apply", "-f", name}))
assert.True(t, ds.ApplyLabelSpecsFuncInvoked)
require.Len(t, appliedLabels, 1)
assert.Equal(t, "manual_label", appliedLabels[0].Name)
assert.Empty(t, appliedLabels[0].Query)
appliedLabels = nil
ds.ApplyLabelSpecsFuncInvoked = false
name = writeTmpYml(t, emptyManualLabelSpec)
assert.Equal(t, "[+] applied 1 labels\n", runAppForTest(t, []string{"apply", "-f", name}))
assert.True(t, ds.ApplyLabelSpecsFuncInvoked)
require.Len(t, appliedLabels, 1)
assert.Equal(t, "empty_manual_label", appliedLabels[0].Name)
assert.Empty(t, appliedLabels[0].Query)
appliedLabels = nil
ds.ApplyLabelSpecsFuncInvoked = false
name = writeTmpYml(t, nohostsManualLabelSpec)
_, err := runAppNoChecks([]string{"apply", "-f", name})
require.Error(t, err)
require.ErrorContains(t, err, "declared as manual but contains no `hosts key`")
}
func TestApplyPacks(t *testing.T) {

View file

@ -944,6 +944,7 @@ apiVersion: v1
kind: label
spec:
description: some description
hosts: null
id: 32
label_membership_type: dynamic
name: label1
@ -954,14 +955,15 @@ apiVersion: v1
kind: label
spec:
description: some other description
hosts: null
id: 33
label_membership_type: dynamic
name: label2
platform: linux
query: select 42;
`
expectedJson := `{"kind":"label","apiVersion":"v1","spec":{"id":32,"name":"label1","description":"some description","query":"select 1;","platform":"windows","label_membership_type":"dynamic"}}
{"kind":"label","apiVersion":"v1","spec":{"id":33,"name":"label2","description":"some other description","query":"select 42;","platform":"linux","label_membership_type":"dynamic"}}
expectedJson := `{"kind":"label","apiVersion":"v1","spec":{"id":32,"name":"label1","description":"some description","query":"select 1;","platform":"windows","label_membership_type":"dynamic","hosts":null}}
{"kind":"label","apiVersion":"v1","spec":{"id":33,"name":"label2","description":"some other description","query":"select 42;","platform":"linux","label_membership_type":"dynamic","hosts":null}}
`
assert.Equal(t, expected, runAppForTest(t, []string{"get", "labels"}))
@ -990,13 +992,14 @@ apiVersion: v1
kind: label
spec:
description: some description
hosts: null
id: 32
label_membership_type: dynamic
name: label1
platform: windows
query: select 1;
`
expectedJson := `{"kind":"label","apiVersion":"v1","spec":{"id":32,"name":"label1","description":"some description","query":"select 1;","platform":"windows","label_membership_type":"dynamic"}}
expectedJson := `{"kind":"label","apiVersion":"v1","spec":{"id":32,"name":"label1","description":"some description","query":"select 1;","platform":"windows","label_membership_type":"dynamic","hosts":null}}
`
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "label", "label1"}))

View file

@ -0,0 +1,31 @@
import { ILabel } from "interfaces/label";
import { IGetLabelResonse } from "services/entities/labels";
const DEFAULT_LABEL_MOCK: ILabel = {
created_at: "2024-04-12T13:32:00Z",
updated_at: "2024-04-12T14:27:07Z",
id: 1,
name: "test label",
description: "test label description",
query: "SELECT 1;",
platform: "darwin",
label_type: "regular",
label_membership_type: "dynamic",
display_text: "test macsss",
count: 0,
host_ids: null,
};
export const createMockLabel = (overrides?: Partial<ILabel>): ILabel => {
return { ...DEFAULT_LABEL_MOCK, ...overrides };
};
const DEFAULT_GET_LABEL_RESPONSE_MOCK: IGetLabelResonse = {
label: createMockLabel(),
};
export const createMockGetLabelResponse = (
overrides?: Partial<IGetLabelResonse>
): IGetLabelResonse => {
return { ...DEFAULT_GET_LABEL_RESPONSE_MOCK, ...overrides };
};

View file

@ -4,7 +4,7 @@
font-weight: $bold;
&--error {
color: $ui-error;
color: $core-vibrant-red;
}
&--with-action {
justify-content: space-between;
@ -18,7 +18,7 @@
&__wrapper {
&--error {
.ace-fleet {
border: 1px solid $ui-error;
border: 1px solid $core-vibrant-red;
}
}
}

View file

@ -30,6 +30,7 @@ import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
import { generateTableHeaders } from "./TargetsInput/TargetsInputHostsTableConfig";
interface ITargetPillSelectorProps {
entity: ISelectLabel | ISelectTeam;
@ -305,9 +306,8 @@ const SelectTargets = ({
: setTargetedTeams(newTargets as ITeam[]);
};
const handleRowSelect = (row: Row) => {
const selectedHost = row.original as IHost;
setTargetedHosts((prevHosts) => prevHosts.concat(selectedHost));
const handleRowSelect = (row: Row<IHost>) => {
setTargetedHosts((prevHosts) => prevHosts.concat(row.original));
setSearchText("");
// If "all hosts" is already selected when using host target picker, deselect "all hosts"
@ -434,6 +434,9 @@ const SelectTargets = ({
);
}
const resultsTableConfig = generateTableHeaders();
const selectedHostsTableConfig = generateTableHeaders(handleRowRemove);
return (
<div className={`${baseClass}__wrapper`}>
<h1>Select targets</h1>
@ -451,6 +454,9 @@ const SelectTargets = ({
renderTargetEntityList("Labels", labels.other)}
</div>
<TargetsInput
autofocus
searchResultsTableConfig={resultsTableConfig}
selectedHostsTableConifg={selectedHostsTableConfig}
tabIndex={inputTabIndex || 0}
searchText={searchText}
searchResults={searchResults || []}
@ -459,7 +465,7 @@ const SelectTargets = ({
hasFetchError={!!errorSearchResults}
setSearchText={setSearchText}
handleRowSelect={handleRowSelect}
handleRowRemove={handleRowRemove}
disablePagination
/>
<div className={`${baseClass}__targets-button-wrap`}>
<Button

View file

@ -9,22 +9,31 @@ import DataError from "components/DataError";
// @ts-ignore
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon/InputFieldWithIcon";
import TableContainer from "components/TableContainer";
import { generateTableHeaders } from "./TargetsInputHostsTableConfig";
import { ITargestInputHostTableConfig } from "./TargetsInputHostsTableConfig";
interface ITargetsInputProps {
tabIndex: number;
tabIndex?: number;
searchText: string;
searchResults: IHost[];
isTargetsLoading: boolean;
hasFetchError: boolean;
targetedHosts: IHost[];
searchResultsTableConfig: ITargestInputHostTableConfig[];
selectedHostsTableConifg: ITargestInputHostTableConfig[];
/** disabled pagination for the results table. The pagination is currently
* client side pagination. Defaults to `false` */
disablePagination?: boolean;
label?: string;
placeholder?: string;
autofocus?: boolean;
setSearchText: (value: string) => void;
handleRowSelect: (value: Row) => void;
handleRowRemove: (value: Row<IHost>) => void;
handleRowSelect: (value: Row<IHost>) => void;
}
const baseClass = "targets-input";
const DEFAULT_LABEL = "Target specific hosts";
const TargetsInput = ({
tabIndex,
searchText,
@ -32,12 +41,15 @@ const TargetsInput = ({
isTargetsLoading,
hasFetchError,
targetedHosts,
searchResultsTableConfig,
selectedHostsTableConifg,
disablePagination = false,
label = DEFAULT_LABEL,
placeholder = HOSTS_SEARCH_BOX_PLACEHOLDER,
autofocus = false,
handleRowSelect,
handleRowRemove,
setSearchText,
}: ITargetsInputProps): JSX.Element => {
const resultsDropdownTableHeaders = generateTableHeaders();
const selectedTableHeaders = generateTableHeaders(handleRowRemove);
const dropdownHosts =
searchResults && pullAllBy(searchResults, targetedHosts, "display_name");
const isActiveSearch =
@ -48,20 +60,20 @@ const TargetsInput = ({
<div>
<div className={baseClass}>
<InputFieldWithIcon
autofocus
autofocus={autofocus}
type="search"
iconSvg="search"
value={searchText}
tabIndex={tabIndex}
iconPosition="start"
label="Target specific hosts"
placeholder={HOSTS_SEARCH_BOX_PLACEHOLDER}
label={label}
placeholder={placeholder}
onChange={setSearchText}
/>
{isActiveSearch && (
<div className={`${baseClass}__hosts-search-dropdown`}>
<TableContainer
columnConfigs={resultsDropdownTableHeaders}
<TableContainer<Row<IHost>>
columnConfigs={searchResultsTableConfig}
data={dropdownHosts}
isLoading={isTargetsLoading}
resultsTitle=""
@ -81,7 +93,7 @@ const TargetsInput = ({
disableCount
disablePagination
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
onClickRow={handleRowSelect}
/>
</div>
)}
@ -92,14 +104,15 @@ const TargetsInput = ({
)}
<div className={`${baseClass}__hosts-selected-table`}>
<TableContainer
columnConfigs={selectedTableHeaders}
columnConfigs={selectedHostsTableConifg}
data={targetedHosts}
isLoading={false}
resultsTitle=""
showMarkAllPages={false}
isAllPagesSelected={false}
disableCount
disablePagination
disablePagination={disablePagination}
isClientSidePagination={!disablePagination}
emptyComponent={() => <></>}
/>
</div>

View file

@ -11,14 +11,14 @@ import LiveQueryIssueCell from "components/TableContainer/DataTable/LiveQueryIss
import StatusIndicator from "components/StatusIndicator";
import Icon from "components/Icon/Icon";
type ITargestInputhostTableConfig = Column<IHost>;
export type ITargestInputHostTableConfig = Column<IHost>;
type ITableStringCellProps = IStringCellProps<IHost>;
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
export const generateTableHeaders = (
handleRowRemove?: (value: Row<IHost>) => void
): ITargestInputhostTableConfig[] => {
): ITargestInputHostTableConfig[] => {
const deleteHeader = handleRowRemove
? [
{

View file

@ -93,5 +93,15 @@
width: 100%;
overflow: auto;
}
.delete__cell {
text-align: center;
}
}
// override the default styles for the spinner.
// TODO: set better default styles for the spinner
.loading-spinner.centered {
margin: auto;
}
}

View file

@ -1,4 +1,4 @@
import React, { ReactChild, useContext } from "react";
import React, { ReactNode, useContext } from "react";
import classnames from "classnames";
import { formatDistanceToNow } from "date-fns";
@ -9,7 +9,7 @@ import SandboxGate from "components/Sandbox/SandboxGate";
import { AppContext } from "context/app";
interface IMainContentProps {
children: ReactChild;
children: ReactNode;
/** An optional classname to pass to the main content component.
* This can be used to apply styles directly onto the main content div
*/

View file

@ -10,12 +10,12 @@ const baseClass = "form-field";
export interface IFormFieldProps {
children: JSX.Element;
className: string;
error: string;
helpText: Array<any> | JSX.Element | string;
label: Array<any> | JSX.Element | string;
name: string;
type: string;
helpText?: Array<any> | JSX.Element | string;
type?: string;
error?: string;
className?: string;
tooltip?: React.ReactNode;
labelTooltipPosition?: PlacesType;
}

View file

@ -40,13 +40,6 @@ export interface ILabel extends ILabelSummary {
platform: string;
}
export interface ILabelFormData {
name: string;
query: string;
description: string;
platform: string;
}
// corresponding to fleet>server>fleet>labels.go>LabelSpec
export interface ILabelSpecResponse {
specs: {

View file

@ -1,285 +0,0 @@
import React, { useState, useEffect } from "react";
import { IAceEditor } from "react-ace/lib/types";
import { noop, size } from "lodash";
import { useDebouncedCallback } from "use-debounce";
import { ILabel, ILabelFormData } from "interfaces/label";
import Button from "components/buttons/Button";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import FleetAce from "components/FleetAce";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
import Icon from "components/Icon/Icon";
interface ILabelFormProps {
baseError: string;
selectedLabel?: ILabel;
isEdit?: boolean;
isUpdatingLabel?: boolean;
onCancel: () => void;
handleSubmit: (formData: ILabelFormData) => void;
onOpenSchemaSidebar: () => void;
onOsqueryTableSelect: (tableName: string) => void;
showOpenSchemaActionText: boolean;
backendValidators: { [key: string]: string };
}
const baseClass = "label-form";
const PLATFORM_STRINGS: { [key: string]: string } = {
darwin: "macOS",
windows: "MS Windows",
ubuntu: "Ubuntu Linux",
centos: "CentOS Linux",
};
const platformOptions = [
{ label: "All platforms", value: "" },
{ label: "macOS", value: "darwin" },
{ label: "Windows", value: "windows" },
{ label: "Ubuntu", value: "ubuntu" },
{ label: "Centos", value: "centos" },
];
const validateQuerySQL = (query: string) => {
const errors: { [key: string]: any } = {};
const { error: queryError, valid: queryValid } = validateQuery(query);
if (!queryValid) {
errors.query = queryError;
}
const valid = !size(errors);
return { valid, errors };
};
const LabelForm = ({
baseError,
selectedLabel,
isEdit,
isUpdatingLabel,
onCancel,
handleSubmit,
onOpenSchemaSidebar,
onOsqueryTableSelect,
showOpenSchemaActionText,
backendValidators,
}: ILabelFormProps): JSX.Element => {
const [name, setName] = useState(selectedLabel?.name || "");
const [nameError, setNameError] = useState("");
const [description, setDescription] = useState(
selectedLabel?.description || ""
);
const [descriptionError, setDescriptionError] = useState("");
const [query, setQuery] = useState(selectedLabel?.query || "");
const [queryError, setQueryError] = useState("");
const [platform, setPlatform] = useState(selectedLabel?.platform || "");
const debounceSQL = useDebouncedCallback((queryString: string) => {
let valid = true;
const { valid: isValidated, errors: newErrors } = validateQuerySQL(
queryString
);
valid = isValidated;
if (query === "") {
setQueryError("");
} else {
setQueryError(newErrors.query);
}
}, 500);
useEffect(() => {
setNameError(backendValidators.name);
setDescriptionError(backendValidators.description);
}, [backendValidators]);
useEffect(() => {
debounceSQL(query);
}, [query]);
const onLoad = (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, value } = data.token;
if (type === "osquery-token" && onOsqueryTableSelect) {
return onOsqueryTableSelect(value);
}
return false;
});
};
const onQueryChange = (value: string) => {
setQuery(value);
};
const onNameChange = (value: string) => {
setName(value);
setNameError("");
};
const onDescriptionChange = (value: string) => {
setDescription(value);
};
const onPlatformChange = (value: string) => {
setPlatform(value);
};
const submitForm = (evt: React.FormEvent) => {
evt.preventDefault();
const { error, valid } = validateQuery(query);
if (!valid) {
setQueryError(error);
return false;
}
setQueryError("");
if (!name) {
setNameError("Label title must be present");
return false;
}
setNameError("");
handleSubmit({
name,
query,
description,
platform,
});
};
const renderLabelComponent = (): JSX.Element | null => {
if (!showOpenSchemaActionText) {
return null;
}
return (
<Button variant="text-icon" onClick={onOpenSchemaSidebar}>
<>
<Icon name="info" size="small" />
Show schema
</>
</Button>
);
};
const isBuiltin =
selectedLabel &&
(selectedLabel.label_type === "builtin" || selectedLabel.type === "status");
const isManual =
selectedLabel && selectedLabel.label_membership_type === "manual";
const headerText = isEdit ? "Edit label" : "New label";
const saveBtnText = isEdit ? "Update label" : "Save label";
const saveBtnClass = isEdit ? "update-label-loading" : "save-label-loading";
const aceHelpText = isEdit
? "Label queries are immutable. To change the query, delete this label and create a new one."
: "";
if (isBuiltin) {
return (
<div className={`${baseClass}__wrapper`}>
<h1>Built in labels cannot be edited</h1>
</div>
);
}
return (
<form
className={`${baseClass}__wrapper`}
onSubmit={submitForm}
autoComplete="off"
>
<h1>{headerText}</h1>
{!isManual && (
<FleetAce
error={queryError}
name="query"
onChange={onQueryChange}
value={query}
label="SQL"
labelActionComponent={renderLabelComponent()}
onLoad={onLoad}
readOnly={isEdit}
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
helpText={aceHelpText}
handleSubmit={noop}
wrapEnabled
focus
/>
)}
{baseError && <div className="form__base-error">{baseError}</div>}
<InputField
error={nameError}
name="name"
onChange={onNameChange}
value={name}
inputClassName={`${baseClass}__label-title`}
label="Name"
placeholder="Label name"
/>
<InputField
error={descriptionError}
name="description"
onChange={onDescriptionChange}
value={description}
inputClassName={`${baseClass}__label-description`}
label="Description"
type="textarea"
placeholder="Label description (optional)"
/>
{!isManual && !isEdit && (
<div className="form-field form-field--dropdown">
<Dropdown
label="Platform"
name="platform"
onChange={onPlatformChange}
value={platform}
options={platformOptions}
classname={`${baseClass}__platform-dropdown`}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
/>
</div>
)}
{isEdit && platform && (
<div className={`${baseClass}__label-platform`}>
<p className="title">Platform</p>
<p>{platform ? PLATFORM_STRINGS[platform] : "All platforms"}</p>
<p className="help-text">
Label platforms are immutable. To change the platform, delete this
label and create a new one.
</p>
</div>
)}
<div className="button-wrap">
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
<Button
type="submit"
variant="brand"
className={saveBtnClass}
isLoading={isUpdatingLabel}
>
{saveBtnText}
</Button>
</div>
</form>
);
};
export default LabelForm;

View file

@ -1,226 +0,0 @@
import React, { useState, useContext } from "react";
import { useQuery } from "react-query";
import { InjectedRouter, Params } from "react-router/lib/Router";
import PATHS from "router/paths";
import MainContent from "components/MainContent";
import SidePanelContent from "components/SidePanelContent";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import Spinner from "components/Spinner";
import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import { IApiError } from "interfaces/errors";
import { ILabel, ILabelFormData } from "interfaces/label";
import labelsAPI, { ILabelsResponse } from "services/entities/labels";
import deepDifference from "utilities/deep_difference";
import useToggleSidePanel from "hooks/useToggleSidePanel";
import LabelForm from "./LabelForm";
const baseClass = "label-page";
interface ILabelPageProps {
router: InjectedRouter;
params: Params;
location: {
pathname: string;
};
}
const DEFAULT_CREATE_LABEL_ERRORS = {
name: "",
};
const LabelPage = ({
router,
params,
location,
}: ILabelPageProps): JSX.Element | null => {
const isEditLabel = !location.pathname.includes("new");
const [selectedLabel, setSelectedLabel] = useState<ILabel>();
const { isSidePanelOpen, setSidePanelOpen } = useToggleSidePanel(true);
const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState(
false
);
const [labelValidator, setLabelValidator] = useState<{
[key: string]: string;
}>(DEFAULT_CREATE_LABEL_ERRORS);
const [isUpdatingLabel, setIsUpdatingLabel] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext(
QueryContext
);
const { renderFlash } = useContext(NotificationContext);
const { error: labelsError } = useQuery<ILabelsResponse, Error, ILabel[]>(
["labels"],
() => labelsAPI.loadAll(),
{
select: (data: ILabelsResponse) => data.labels,
onSuccess: (responseLabels: ILabel[]) => {
if (params.label_id) {
const selectLabel = responseLabels.find(
(label) => label.id === parseInt(params.label_id, 10)
);
setSelectedLabel(selectLabel);
setIsLoading(false);
}
},
}
);
const onCloseSchemaSidebar = () => {
setSidePanelOpen(false);
setShowOpenSchemaActionText(true);
};
const onOpenSchemaSidebar = () => {
setSidePanelOpen(true);
setShowOpenSchemaActionText(false);
};
const onOsqueryTableSelect = (tableName: string) => {
setSelectedOsqueryTable(tableName);
};
const onEditLabel = (formData: ILabelFormData) => {
if (!selectedLabel) {
console.error("Label isn't available. This should not happen.");
return;
}
setIsUpdatingLabel(true);
const updateAttrs = deepDifference(formData, selectedLabel);
labelsAPI
.update(selectedLabel, updateAttrs)
.then(() => {
router.push(PATHS.MANAGE_HOSTS_LABEL(selectedLabel.id));
renderFlash(
"success",
"Label updated. Try refreshing this page in just a moment to see the updated host count for your label."
);
})
.catch((updateError: { data: IApiError }) => {
if (updateError.data.errors[0].reason.includes("Duplicate")) {
setLabelValidator({
name: "A label with this name already exists",
});
} else if (updateError.data.errors[0].reason.includes("built-in")) {
setLabelValidator({
name: "A built-in label with this name already exists",
});
} else if (
updateError.data.errors[0].reason.includes(
"Data too long for column 'name'"
)
) {
setLabelValidator({
name: "Label name is too long",
});
} else if (
updateError.data.errors[0].reason.includes(
"Data too long for column 'description'"
)
) {
setLabelValidator({
description: "Label description is too long",
});
} else {
renderFlash("error", "Could not create label. Please try again.");
}
})
.finally(() => {
setIsUpdatingLabel(false);
});
};
const onAddLabel = (formData: ILabelFormData) => {
setIsUpdatingLabel(true);
labelsAPI
.create(formData)
.then((label: ILabel) => {
router.push(PATHS.MANAGE_HOSTS_LABEL(label.id));
renderFlash(
"success",
"Label created. Try refreshing this page in just a moment to see the updated host count for your label."
);
})
.catch((updateError: { data: IApiError }) => {
if (updateError.data.errors[0].reason.includes("Duplicate")) {
setLabelValidator({
name: "A label with this name already exists",
});
} else if (updateError.data.errors[0].reason.includes("built-in")) {
setLabelValidator({
name: "A built-in label with this name already exists",
});
} else if (
updateError.data.errors[0].reason.includes(
"Data too long for column 'name'"
)
) {
setLabelValidator({
name: "Label name is too long",
});
} else if (
updateError.data.errors[0].reason.includes(
"Data too long for column 'description'"
)
) {
setLabelValidator({
description: "Label description is too long",
});
} else {
renderFlash("error", "Could not create label. Please try again.");
}
})
.finally(() => {
setIsUpdatingLabel(false);
});
};
const onCancelLabel = () => {
router.goBack();
};
return (
<>
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
{isLoading ? (
<Spinner />
) : (
<LabelForm
selectedLabel={selectedLabel}
onCancel={onCancelLabel}
isEdit={isEditLabel}
isUpdatingLabel={isUpdatingLabel}
handleSubmit={isEditLabel ? onEditLabel : onAddLabel}
onOpenSchemaSidebar={onOpenSchemaSidebar}
onOsqueryTableSelect={onOsqueryTableSelect}
baseError={labelsError?.message || ""}
backendValidators={labelValidator}
showOpenSchemaActionText={showOpenSchemaActionText}
/>
)}
</div>
</MainContent>
{isSidePanelOpen && !isEditLabel && (
<SidePanelContent>
<QuerySidePanel
key="query-side-panel"
onOsqueryTableSelect={onOsqueryTableSelect}
selectedOsqueryTable={selectedOsqueryTable}
onClose={onCloseSchemaSidebar}
/>
</SidePanelContent>
)}
</>
);
};
export default LabelPage;

View file

@ -1,5 +0,0 @@
.label-page {
&__sandboxMode {
margin-top: 70px;
}
}

View file

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

View file

@ -0,0 +1,139 @@
import React, { useContext } from "react";
import { useQuery } from "react-query";
import { RouteComponentProps } from "react-router";
import { AxiosError } from "axios";
import PATHS from "router/paths";
import labelsAPI, { IGetLabelResonse } from "services/entities/labels";
import hostAPI from "services/entities/hosts";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { ILabel } from "interfaces/label";
import { IHost } from "interfaces/host";
import { NotificationContext } from "context/notification";
import MainContent from "components/MainContent";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import DynamicLabelForm from "../components/DynamicLabelForm";
import ManualLabelForm from "../components/ManualLabelForm";
import { IDynamicLabelFormData } from "../components/DynamicLabelForm/DynamicLabelForm";
import { IManualLabelFormData } from "../components/ManualLabelForm/ManualLabelForm";
const baseClass = "edit-label-page";
interface IEditLabelPageRouteParams {
label_id: string;
}
type IEditLabelPageProps = RouteComponentProps<
never,
IEditLabelPageRouteParams
>;
const EditLabelPage = ({ routeParams, router }: IEditLabelPageProps) => {
const { renderFlash } = useContext(NotificationContext);
const labelId = parseInt(routeParams.label_id, 10);
const {
data: label,
isLoading: isLoadingLabel,
isError: isErrorLabel,
} = useQuery<IGetLabelResonse, AxiosError, ILabel>(
["label", labelId],
() => labelsAPI.getLabel(labelId),
{
...DEFAULT_USE_QUERY_OPTIONS,
select: (data) => data.label,
}
);
// TODO: clean this up when API allows getting hosts by
// host ids in a single request. We need to make another request when
// the label is manual to get the host data for the targeted hosts.
const {
data: targetedHosts,
isLoading: isLoadingHosts,
isError: isErrorHosts,
} = useQuery<{ host: IHost }[], AxiosError, IHost[]>(
["hosts"],
() => {
return hostAPI.getHosts(label?.host_ids ?? []);
},
{
...DEFAULT_USE_QUERY_OPTIONS,
select: (res) => res.map((host) => host.host),
enabled: label?.label_membership_type === "manual",
}
);
const onCancelEdit = () => {
router.goBack();
};
const onUpdateLabel = async (
formData: IDynamicLabelFormData | IManualLabelFormData
) => {
try {
const res = await labelsAPI.update(labelId, formData);
router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id));
renderFlash("success", "Label updated successfully.");
} catch {
renderFlash("error", "Couldn't edit label. Please try again.");
}
};
const renderContent = () => {
if (isLoadingLabel || isLoadingHosts) {
return <Spinner />;
}
if (isErrorLabel || isErrorHosts) {
return <DataError />;
}
if (!label) return null;
if (label.label_type === "builtin") {
return (
<DataError
description="Built in labels cannot be edited"
excludeIssueLink
/>
);
}
return label.label_membership_type === "dynamic" ? (
<DynamicLabelForm
defaultName={label.name}
defaultDescription={label.description}
defaultQuery={label.query}
defaultPlatform={label.platform}
isEditing
onSave={onUpdateLabel}
onCancel={onCancelEdit}
/>
) : (
<ManualLabelForm
key={targetedHosts?.toString()}
defaultName={label.name}
defaultDescription={label.description}
defaultTargetedHosts={targetedHosts}
onSave={onUpdateLabel}
onCancel={onCancelEdit}
/>
);
};
return (
<>
<MainContent className={baseClass}>
<h1>Edit label</h1>
{renderContent()}
</MainContent>
</>
);
};
export default EditLabelPage;

View file

@ -0,0 +1,5 @@
.edit-label-page {
h1 {
margin-bottom: $pad-xxlarge;
}
}

View file

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

View file

@ -0,0 +1,57 @@
import React, { useContext } from "react";
import { RouteComponentProps } from "react-router";
import PATHS from "router/paths";
import labelsAPI from "services/entities/labels";
import { NotificationContext } from "context/notification";
import DynamicLabelForm from "pages/labels/components/DynamicLabelForm";
import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm";
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 { renderFlash } = useContext(NotificationContext);
const onSaveNewLabel = async (formData: IDynamicLabelFormData) => {
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.");
}
};
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

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

View file

@ -0,0 +1,39 @@
import React, { useContext } from "react";
import { RouteComponentProps } from "react-router";
import PATHS from "router/paths";
import labelsAPI from "services/entities/labels";
import { NotificationContext } from "context/notification";
import ManualLabelForm from "pages/labels/components/ManualLabelForm";
import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm";
const baseClass = "manual-label";
type IManualLabelProps = RouteComponentProps<never, never>;
const ManualLabel = ({ router }: IManualLabelProps) => {
const { renderFlash } = useContext(NotificationContext);
const onSaveNewLabel = async (formData: IManualLabelFormData) => {
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.");
}
};
const onCancelLabel = () => {
router.goBack();
};
return (
<div className={baseClass}>
<ManualLabelForm onSave={onSaveNewLabel} onCancel={onCancelLabel} />
</div>
);
};
export default ManualLabel;

View file

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

View file

@ -0,0 +1,118 @@
import React, { useCallback, useContext, useState } from "react";
import { Tab, TabList, Tabs } from "react-tabs";
import { QueryContext } from "context/query";
import useToggleSidePanel from "hooks/useToggleSidePanel";
import MainContent from "components/MainContent";
import SidePanelContent from "components/SidePanelContent";
import TabsWrapper from "components/TabsWrapper";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import PATHS from "router/paths";
import { RouteComponentProps } from "react-router";
interface ILabelSubNavItem {
name: string;
pathname: string;
}
const labelSubNav: ILabelSubNavItem[] = [
{
name: "Dynamic",
pathname: PATHS.LABEL_NEW_DYNAMIC,
},
{
name: "Manual",
pathname: PATHS.LABEL_NEW_MANUAL,
},
];
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;
}
const NewLabelPage = ({ router, location, children }: INewLabelPageProps) => {
const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext(
QueryContext
);
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]
);
const onCloseSidebar = () => {
setSidePanelOpen(false);
setShowOpenSidebarButton(true);
};
const onOpenSidebar = () => {
setSidePanelOpen(true);
setShowOpenSidebarButton(false);
};
const onOsqueryTableSelect = (tableName: string) => {
setSelectedOsqueryTable(tableName);
};
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>
<TabsWrapper className={`${baseClass}__new-label-tabs-wrapper`}>
<Tabs
selectedIndex={getTabIndex(location?.pathname || "")}
onSelect={navigateToNav}
>
<TabList>
{labelSubNav.map((navItem) => {
return (
<Tab key={navItem.name} data-text={navItem.name}>
{navItem.name}
</Tab>
);
})}
</TabList>
</Tabs>
</TabsWrapper>
{React.cloneElement(children, {
showOpenSidebarButton,
onOpenSidebar,
onOsqueryTableSelect,
})}
</MainContent>
{isDynamicLabel && isSidePanelOpen && (
<SidePanelContent>
<QuerySidePanel
key="query-side-panel"
onOsqueryTableSelect={onOsqueryTableSelect}
selectedOsqueryTable={selectedOsqueryTable}
onClose={onCloseSidebar}
/>
</SidePanelContent>
)}
</>
);
};
export default NewLabelPage;

View file

@ -0,0 +1,15 @@
.new-label-page {
h1 {
margin-bottom: $pad-large;
}
&__page-description {
margin: 0;
color: $ui-fleet-black-75;
font-size: $xx-small;
}
&__new-label-tabs-wrapper {
margin-bottom: $pad-xxlarge;
}
}

View file

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

View file

@ -0,0 +1,153 @@
import React, { useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { IAceEditor } from "react-ace/lib/types";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
import FleetAce from "components/FleetAce";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import LabelForm from "../LabelForm";
import { ILabelFormData } from "../LabelForm/LabelForm";
import PlatformField from "../PlatformField";
const baseClass = "dynamic-label-form";
const IMMUTABLE_QUERY_HELP_TEXT =
"Label queries are immutable. To change the query, delete this label and create a new one.";
export interface IDynamicLabelFormData {
name: string;
description: string;
query: string;
platform: string;
}
interface IDynamicLabelFormProps {
defaultName?: string;
defaultDescription?: string;
defaultQuery?: string;
defaultPlatform?: string;
showOpenSidebarButton?: boolean;
isEditing?: boolean;
onOpenSidebar?: () => void;
onOsqueryTableSelect?: (tableName: string) => void;
onSave: (formData: IDynamicLabelFormData) => void;
onCancel: () => void;
}
const DynamicLabelForm = ({
defaultName = "",
defaultDescription = "",
defaultQuery = "",
defaultPlatform = "",
isEditing = false,
showOpenSidebarButton = false,
onOpenSidebar,
onOsqueryTableSelect,
onSave,
onCancel,
}: IDynamicLabelFormProps) => {
const [query, setQuery] = useState(defaultQuery);
const [platform, setPlatform] = useState(defaultPlatform);
const [queryError, setQueryError] = useState<string | null>(null);
const debounceValidateSQL = useDebouncedCallback((queryString: string) => {
const { error } = validateQuery(queryString);
if (query === "" || error === "") {
setQueryError(null);
} else {
setQueryError(error);
}
}, 500);
const onQueryChange = (newQuery: string) => {
setQuery(newQuery);
debounceValidateSQL(newQuery);
};
const onSaveForm = (
labelFormData: ILabelFormData,
labelFormDataValid: boolean
) => {
const { error } = validateQuery(query);
if (error) {
setQueryError(error);
} else if (labelFormDataValid) {
// values from LabelForm component must be valid too
onSave({ ...labelFormData, query, platform });
}
};
const renderLabelComponent = (): JSX.Element | null => {
if (!showOpenSidebarButton) {
return null;
}
return (
<Button variant="text-icon" onClick={onOpenSidebar}>
<Icon name="info" size="small" />
<span>Show schema</span>
</Button>
);
};
const onLoad = (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, value } = data.token;
if (type === "osquery-token" && onOsqueryTableSelect) {
return onOsqueryTableSelect(value);
}
return false;
});
};
const onChangePlatform = (value: string) => {
setPlatform(value);
};
return (
<div className={baseClass}>
<LabelForm
defaultName={defaultName}
defaultDescription={defaultDescription}
onSave={onSaveForm}
onCancel={onCancel}
additionalFields={
<>
<FleetAce
error={queryError}
name="query"
onChange={onQueryChange}
value={query}
label="Query"
labelActionComponent={renderLabelComponent()}
readOnly={isEditing}
onLoad={onLoad}
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
helpText={isEditing ? IMMUTABLE_QUERY_HELP_TEXT : ""}
wrapEnabled
/>
<PlatformField
platform={platform}
isEditing={isEditing}
onChange={onChangePlatform}
/>
</>
}
/>
</div>
);
};
export default DynamicLabelForm;

View file

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

View file

@ -0,0 +1,91 @@
import React, { ReactNode, useState } from "react";
import validate_presence from "components/forms/validators/validate_presence";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Button from "components/buttons/Button";
export interface ILabelFormData {
name: string;
description: string;
}
interface ILabelFormProps {
defaultName?: string;
defaultDescription?: string;
additionalFields?: ReactNode;
isUpdatingLabel?: boolean;
onCancel: () => void;
onSave: (formData: ILabelFormData, isValid: boolean) => void;
}
const baseClass = "label-form";
const LabelForm = ({
defaultName = "",
defaultDescription = "",
additionalFields,
isUpdatingLabel,
onCancel,
onSave,
}: ILabelFormProps) => {
const [name, setName] = useState(defaultName);
const [description, setDescription] = useState(defaultDescription);
const [nameError, setNameError] = useState<string | null>("");
const onNameChange = (value: string) => {
setName(value);
setNameError(null);
};
const onDescriptionChange = (value: string) => {
setDescription(value);
};
const onSubmitForm = (evt: React.FormEvent) => {
evt.preventDefault();
let isFormValid = true;
if (!validate_presence(name)) {
setNameError("Label name must be present");
isFormValid = false;
}
onSave({ name, description }, isFormValid);
};
return (
<form className={`${baseClass}__wrapper`} onSubmit={onSubmitForm}>
<InputField
error={nameError}
name="name"
onChange={onNameChange}
value={name}
inputClassName={`${baseClass}__label-title`}
label="Name"
placeholder="Label name"
/>
<InputField
name="description"
onChange={onDescriptionChange}
value={description}
inputClassName={`${baseClass}__label-description`}
label="Description"
type="textarea"
placeholder="Label description (optional)"
/>
{additionalFields}
<div className="button-wrap">
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
<Button type="submit" variant="brand" isLoading={isUpdatingLabel}>
Save
</Button>
</div>
</form>
);
};
export default LabelForm;

View file

@ -2,6 +2,7 @@
&__label-title,
&__label-description {
width: 100%;
resize: vertical;
}
&__label-platform {

View file

@ -0,0 +1,55 @@
/* eslint-disable react/prop-types */
import React from "react";
import { Column, Row } from "react-table";
import { IStringCellProps } from "interfaces/datatable_config";
import { IHost } from "interfaces/host";
import TextCell from "components/TableContainer/DataTable/TextCell";
import Icon from "components/Icon/Icon";
export type ITargestInputHostTableConfig = Column<IHost>;
type ITableStringCellProps = IStringCellProps<IHost>;
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
export const generateTableHeaders = (
handleRowRemove?: (value: Row<IHost>) => void
): ITargestInputHostTableConfig[] => {
const deleteHeader = handleRowRemove
? [
{
id: "delete",
Header: "",
Cell: (cellProps: ITableStringCellProps) => (
<div onClick={() => handleRowRemove(cellProps.row)}>
<Icon name="close-filled" />
</div>
),
disableHidden: true,
},
]
: [];
return [
{
Header: "Host",
accessor: "display_name",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
{
Header: "Hostname",
accessor: "hostname",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
{
Header: "Serial number",
accessor: "hardware_serial",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
...deleteHeader,
];
};
export default null;

View file

@ -0,0 +1,153 @@
import React, { useEffect, useState } from "react";
import { useQuery } from "react-query";
import { Row } from "react-table";
import { useDebouncedCallback } from "use-debounce";
import { IHost } from "interfaces/host";
import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets";
import TargetsInput from "components/LiveQuery/TargetsInput";
import LabelForm from "../LabelForm";
import { ILabelFormData } from "../LabelForm/LabelForm";
import { generateTableHeaders } from "./LabelHostTargetTableConfig";
const baseClass = "ManualLabelForm";
const LABEL_TARGET_HOSTS_INPUT_LABEL = "Select hosts";
const LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER =
"Search name, hostname, or serial number";
const DEBOUNCE_DELAY = 500;
export interface IManualLabelFormData {
name: string;
description: string;
targetedHosts: IHost[];
}
interface ITargetsQueryKey {
scope: string;
query?: string | null;
excludedHostIds?: number[];
}
interface IManualLabelFormProps {
defaultName?: string;
defaultDescription?: string;
defaultTargetedHosts?: IHost[];
onSave: (formData: IManualLabelFormData) => void;
onCancel: () => void;
}
const ManualLabelForm = ({
defaultName = "",
defaultDescription = "",
defaultTargetedHosts = [],
onSave,
onCancel,
}: IManualLabelFormProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [isDebouncing, setIsDebouncing] = useState(false);
const [targetedHosts, setTargetedHosts] = useState<IHost[]>(
defaultTargetedHosts
);
const targetdHostsIds = targetedHosts.map((host) => host.id);
const debounceSearch = useDebouncedCallback(
(search: string) => {
setDebouncedSearchQuery(search);
setIsDebouncing(false);
},
DEBOUNCE_DELAY,
{ trailing: true }
);
// TODO: find a better way to debounce search requests
useEffect(() => {
setIsDebouncing(true);
debounceSearch(searchQuery);
}, [debounceSearch, searchQuery]);
const {
data: hostTargets,
isLoading: isLoadingSearchResults,
isError: isErrorSearchResults,
} = useQuery<ITargetsSearchResponse, Error, IHost[], ITargetsQueryKey[]>(
[
{
scope: "labels-targets-search",
query: debouncedSearchQuery,
excludedHostIds: targetdHostsIds,
},
],
({ queryKey }) => {
const { query, excludedHostIds } = queryKey[0];
return targetsAPI.search({
query: query ?? "",
excluded_host_ids: excludedHostIds ?? null,
});
},
{
select: (data) => data.hosts,
enabled: searchQuery !== "",
}
);
const onHostSelect = (row: Row<IHost>) => {
setTargetedHosts((prevHosts) => prevHosts.concat(row.original));
setSearchQuery("");
};
const onHostRemove = (row: Row<IHost>) => {
setTargetedHosts((prevHosts) =>
prevHosts.filter((h) => h.id !== row.original.id)
);
};
const onSaveNewLabel = (
labelFormData: ILabelFormData,
labelFormDataValid: boolean
) => {
if (labelFormDataValid) {
// values from LabelForm component must be valid too
onSave({ ...labelFormData, targetedHosts });
}
};
const onChangeSearchQuery = (value: string) => {
setSearchQuery(value);
};
const resultsTableConfig = generateTableHeaders();
const selectedHostsTableConfig = generateTableHeaders(onHostRemove);
return (
<div className={baseClass}>
<LabelForm
defaultName={defaultName}
defaultDescription={defaultDescription}
onCancel={onCancel}
onSave={onSaveNewLabel}
additionalFields={
<TargetsInput
label={LABEL_TARGET_HOSTS_INPUT_LABEL}
placeholder={LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER}
searchText={searchQuery}
searchResultsTableConfig={resultsTableConfig}
selectedHostsTableConifg={selectedHostsTableConfig}
isTargetsLoading={isLoadingSearchResults || isDebouncing}
hasFetchError={isErrorSearchResults}
searchResults={hostTargets ?? []}
targetedHosts={targetedHosts}
setSearchText={onChangeSearchQuery}
handleRowSelect={onHostSelect}
/>
}
/>
</div>
);
};
export default ManualLabelForm;

View file

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

View file

@ -0,0 +1,66 @@
import React from "react";
import { noop } from "lodash";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import FormField from "components/forms/FormField";
const PLATFORM_STRINGS: { [key: string]: string } = {
darwin: "macOS",
windows: "MS Windows",
ubuntu: "Ubuntu Linux",
centos: "CentOS Linux",
};
const platformOptions = [
{ label: "All platforms", value: "" },
{ label: "macOS", value: "darwin" },
{ label: "Windows", value: "windows" },
{ label: "Ubuntu", value: "ubuntu" },
{ label: "Centos", value: "centos" },
];
const baseClass = "platform-field";
interface IPlatformFieldProps {
platform: string;
isEditing?: boolean;
onChange?: (platform: string) => void;
}
const PlatformField = ({
platform,
isEditing = false,
onChange = noop,
}: IPlatformFieldProps) => {
return (
<div className={baseClass}>
{!isEditing ? (
<div className="form-field form-field--dropdown">
<Dropdown
label="Platform"
name="platform"
onChange={onChange}
value={platform}
options={platformOptions}
classname={`${baseClass}__platform-dropdown`}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
/>
</div>
) : (
<FormField
label="Platform"
name="platform"
helpText="Label platforms are immutable. To change the platform, delete this
label and create a new one."
>
<>
<p>{platform ? PLATFORM_STRINGS[platform] : "All platforms"}</p>
</>
</FormField>
)}
</div>
);
};
export default PlatformField;

View file

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

View file

@ -25,7 +25,10 @@ import EmailTokenRedirect from "components/EmailTokenRedirect";
import ForgotPasswordPage from "pages/ForgotPasswordPage";
import GatedLayout from "layouts/GatedLayout";
import HostDetailsPage from "pages/hosts/details/HostDetailsPage";
import LabelPage from "pages/LabelPage";
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";
import ManageHostsPage from "pages/hosts/ManageHostsPage";
@ -169,9 +172,13 @@ const routes = (
<Redirect from="teams/:team_id/options" to="teams" />
</Route>
<Route path="labels">
<IndexRedirect to="new" />
<Route path=":label_id" component={LabelPage} />
<Route path="new" component={LabelPage} />
<IndexRedirect to="new/dynamic" />
<Route path="new" component={NewLabelPage}>
<IndexRedirect to="dynamic" />
<Route path="dynamic" component={DynamicLabel} />
<Route path="manual" component={ManualLabel} />
</Route>
<Route path=":label_id" component={EditLabelPage} />
</Route>
<Route path="hosts">
<IndexRedirect to="manage" />

View file

@ -17,6 +17,7 @@ export default {
CONTROLS_SETUP_ASSITANT: `${URL_PREFIX}/controls/setup-experience/setup-assistant`,
CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`,
// Dashboard pages
DASHBOARD: `${URL_PREFIX}/dashboard`,
DASHBOARD_LINUX: `${URL_PREFIX}/dashboard/linux`,
DASHBOARD_MAC: `${URL_PREFIX}/dashboard/mac`,
@ -65,6 +66,11 @@ export default {
return `${URL_PREFIX}/software/vulnerabilities/${cve}`;
},
// Label pages
LABEL_NEW_DYNAMIC: `${URL_PREFIX}/labels/new/dynamic`,
LABEL_NEW_MANUAL: `${URL_PREFIX}/labels/new/manual`,
LABEL_EDIT: (labelId: number) => `${URL_PREFIX}/labels/${labelId}`,
EDIT_PACK: (packId: number): string => {
return `${URL_PREFIX}/packs/${packId}/edit`;
},

View file

@ -315,6 +315,18 @@ export default {
return sendRequest("GET", path);
},
// TODO: change/remove this when backend implments way for client to get
// a collection of hosts based on ho st ids
getHosts: (hostIds: number[]) => {
return Promise.all(
hostIds.map((hostId) => {
const { HOSTS } = endpoints;
const path = `${HOSTS}/${hostId}`;
return sendRequest("GET", path);
})
);
},
loadHosts: ({
page = 0,
perPage = 100,

View file

@ -2,7 +2,10 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import helpers from "utilities/helpers";
import { ILabel, ILabelFormData, ILabelSummary } from "interfaces/label";
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";
export interface ILabelsResponse {
labels: ILabel[];
@ -12,27 +15,49 @@ export interface ILabelsSummaryResponse {
labels: ILabelSummary[];
}
export interface ICreateLabelResponse {
label: ILabel;
}
export type IUpdateLabelResponse = ICreateLabelResponse;
export type IGetLabelResonse = ICreateLabelResponse;
const isManualLabelFormData = (
formData: IDynamicLabelFormData | IManualLabelFormData
): formData is IManualLabelFormData => {
return "targetedHosts" in formData;
};
const getUniqueHostIdentifier = (host: IHost) => {
return host.hardware_serial || host.uuid || host.hostname;
};
const generateCreateLabelBody = (
formData: IDynamicLabelFormData | IManualLabelFormData
) => {
// we need to prepare the post body for only manual labels.
if (isManualLabelFormData(formData)) {
return {
name: formData.name,
description: formData.description,
hosts: formData.targetedHosts.map((host) =>
getUniqueHostIdentifier(host)
),
};
}
return formData;
};
const generateUpdateLabelBody = generateCreateLabelBody;
export default {
create: async (formData: ILabelFormData) => {
create: (
formData: IDynamicLabelFormData | IManualLabelFormData
): Promise<ICreateLabelResponse> => {
const { LABELS } = endpoints;
try {
const { label: createdLabel } = await sendRequest(
"POST",
LABELS,
formData
);
return {
...createdLabel,
slug: helpers.labelSlug(createdLabel),
type: "custom",
};
} catch (error) {
console.error(error);
throw error;
}
const postBody = generateCreateLabelBody(formData);
return sendRequest("POST", LABELS, postBody);
},
destroy: (label: ILabel) => {
const { LABELS } = endpoints;
const path = `${LABELS}/id/${label.id}`;
@ -52,34 +77,28 @@ export default {
}
},
summary: (): Promise<ILabelsSummaryResponse> => {
const { LABELS } = endpoints;
const path = `${LABELS}/summary`;
const { LABELS_SUMMARY } = endpoints;
return sendRequest("GET", path);
return sendRequest("GET", LABELS_SUMMARY);
},
update: async (label: ILabel, updatedAttrs: ILabel) => {
const { LABELS } = endpoints;
const path = `${LABELS}/${label.id}`;
try {
const { label: updatedLabel } = await sendRequest(
"PATCH",
path,
updatedAttrs
);
return {
...updatedLabel,
slug: helpers.labelSlug(updatedLabel),
type: "custom",
};
} catch (error) {
console.error(error);
throw error;
}
update: async (
labelId: number,
formData: IDynamicLabelFormData | IManualLabelFormData
): Promise<IUpdateLabelResponse> => {
const { LABEL } = endpoints;
const updateAttrs = generateUpdateLabelBody(formData);
return sendRequest("PATCH", LABEL(labelId), updateAttrs);
},
specByName: (labelName: string) => {
const { LABEL_SPEC_BY_NAME } = endpoints;
const path = LABEL_SPEC_BY_NAME(labelName);
return sendRequest("GET", path);
},
getLabel: (labelId: number): Promise<IGetLabelResonse> => {
const { LABEL } = endpoints;
return sendRequest("GET", LABEL(labelId));
},
};

View file

@ -48,13 +48,18 @@ export default {
`/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/resend/${profileUUID}`,
INVITES: `/${API_VERSION}/fleet/invites`,
// labels
LABEL: (id: number) => `/${API_VERSION}/fleet/labels/${id}`,
LABELS: `/${API_VERSION}/fleet/labels`,
LABELS_SUMMARY: `/${API_VERSION}/fleet/labels/summary`,
LABEL_HOSTS: (id: number): string => {
return `/${API_VERSION}/fleet/labels/${id}/hosts`;
},
LABEL_SPEC_BY_NAME: (labelName: string) => {
return `/${API_VERSION}/fleet/spec/labels/${labelName}`;
},
LOGIN: `/${API_VERSION}/fleet/login`,
LOGOUT: `/${API_VERSION}/fleet/logout`,
MACADMINS: `/${API_VERSION}/fleet/macadmins`,

View file

@ -764,7 +764,7 @@ const hostMDMSelect = `,
ELSE NULL
END,
'dep_profile_error',
CASE
CASE
WHEN hdep.assign_profile_response = '` + string(fleet.DEPAssignProfileResponseFailed) + `' THEN CAST(TRUE AS JSON)
ELSE CAST(FALSE AS JSON)
END,
@ -5076,3 +5076,35 @@ func (ds *Datastore) loadHostLite(ctx context.Context, id *uint, identifier *str
return host, nil
}
// HostnamesByIdentifiers returns a list of hostnames (exactly the
// "hosts.hostname" column) for the given identifiers as understood by
// HostByIdentifier.
func (ds *Datastore) HostnamesByIdentifiers(ctx context.Context, identifiers []string) ([]string, error) {
const selectStmt = `
SELECT
DISTINCT h.hostname
FROM hosts h,
(%s) ids
WHERE ids.identifier IN (h.hostname, h.osquery_host_id, h.node_key, h.uuid, h.hardware_serial)
`
if len(identifiers) == 0 {
return nil, nil
}
var sb strings.Builder
args := make([]any, len(identifiers))
for i, id := range identifiers {
if i > 0 {
sb.WriteString(` UNION `)
}
sb.WriteString(`SELECT ? AS identifier`)
args[i] = id
}
var hostnames []string
stmt := fmt.Sprintf(selectStmt, sb.String())
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostnames, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get hostnames by identifiers")
}
return hostnames, nil
}

View file

@ -164,6 +164,7 @@ func TestHosts(t *testing.T) {
{"LastRestarted", testLastRestarted},
{"HostHealth", testHostHealth},
{"GetHostOrbitInfo", testGetHostOrbitInfo},
{"HostnamesByIdentifiers", testHostnamesByIdentifiers},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -8795,3 +8796,67 @@ func testGetHostOrbitInfo(t *testing.T, ds *Datastore) {
require.NoError(t, err)
assert.True(t, *hostOrbitInfo.ScriptsEnabled)
}
func testHostnamesByIdentifiers(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create a few hosts with different identifiers
h1, err := ds.NewHost(
ctx, &fleet.Host{
DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(), SeenTime: time.Now(),
NodeKey: ptr.String("abc"),
UUID: "def",
Hostname: "ghi.local",
HardwareSerial: "jkl",
},
)
require.NoError(t, err)
require.NotNil(t, h1)
h2, err := ds.NewHost(
ctx, &fleet.Host{
DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(), SeenTime: time.Now(),
NodeKey: ptr.String("def"),
UUID: "mno",
Hostname: "pqr.local",
HardwareSerial: "sty",
},
)
require.NoError(t, err)
require.NotNil(t, h2)
h3, err := ds.NewHost(
ctx, &fleet.Host{
DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(), SeenTime: time.Now(),
NodeKey: ptr.String("mno"),
UUID: "vwx",
Hostname: "yzA.local",
HardwareSerial: "def",
},
)
require.NoError(t, err)
require.NotNil(t, h3)
cases := []struct {
desc string
in []string
out []string
}{
{desc: "no identifier", in: nil, out: nil},
{desc: "no match", in: []string{"ZZZ"}, out: nil},
{desc: "single match", in: []string{"abc"}, out: []string{h1.Hostname}},
{desc: "two matches", in: []string{"mno"}, out: []string{h2.Hostname, h3.Hostname}},
{desc: "all matches", in: []string{"def"}, out: []string{h1.Hostname, h2.Hostname, h3.Hostname}},
{desc: "multiple identifiers", in: []string{"abc", "mno", "vwx"}, out: []string{h1.Hostname, h2.Hostname, h3.Hostname}},
{desc: "duplicate identifiers", in: []string{"abc", "abc", "ghi"}, out: []string{h1.Hostname}},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
got, err := ds.HostnamesByIdentifiers(ctx, c.in)
require.NoError(t, err)
require.ElementsMatch(t, c.out, got)
})
}
}

View file

@ -220,11 +220,11 @@ func (ds *Datastore) NewLabel(ctx context.Context, label *fleet.Label, opts ...f
return label, nil
}
func (ds *Datastore) SaveLabel(ctx context.Context, label *fleet.Label) (*fleet.Label, error) {
func (ds *Datastore) SaveLabel(ctx context.Context, label *fleet.Label) (*fleet.Label, []uint, error) {
query := `UPDATE labels SET name = ?, description = ? WHERE id = ?`
_, err := ds.writer(ctx).ExecContext(ctx, query, label.Name, label.Description, label.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "saving label")
return nil, nil, ctxerr.Wrap(ctx, err, "saving label")
}
return labelDB(ctx, label.ID, ds.writer(ctx))
}
@ -261,11 +261,11 @@ func (ds *Datastore) DeleteLabel(ctx context.Context, name string) error {
}
// Label returns a fleet.Label identified by lid if one exists.
func (ds *Datastore) Label(ctx context.Context, lid uint) (*fleet.Label, error) {
func (ds *Datastore) Label(ctx context.Context, lid uint) (*fleet.Label, []uint, error) {
return labelDB(ctx, lid, ds.reader(ctx))
}
func labelDB(ctx context.Context, lid uint, q sqlx.QueryerContext) (*fleet.Label, error) {
func labelDB(ctx context.Context, lid uint, q sqlx.QueryerContext) (*fleet.Label, []uint, error) {
stmt := `
SELECT
l.*,
@ -277,12 +277,19 @@ func labelDB(ctx context.Context, lid uint, q sqlx.QueryerContext) (*fleet.Label
if err := sqlx.GetContext(ctx, q, label, stmt, lid); err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("Label").WithID(lid))
return nil, nil, ctxerr.Wrap(ctx, notFound("Label").WithID(lid))
}
return nil, ctxerr.Wrap(ctx, err, "selecting label")
return nil, nil, ctxerr.Wrap(ctx, err, "selecting label")
}
return label, nil
var hostIDs []uint
if label.LabelMembershipType == fleet.LabelMembershipTypeManual && label.HostCount > 0 {
if err := sqlx.SelectContext(ctx, q, &hostIDs, `SELECT host_id FROM label_membership WHERE label_id = ?`, lid); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "selecting label host IDs")
}
}
return label, hostIDs, nil
}
// ListLabels returns all labels limited or sorted by fleet.ListOptions.

View file

@ -216,9 +216,9 @@ func testLabelsSearch(t *testing.T, db *Datastore) {
err := db.ApplyLabelSpecs(context.Background(), specs)
require.Nil(t, err)
all, err := db.Label(context.Background(), specs[len(specs)-1].ID)
all, _, err := db.Label(context.Background(), specs[len(specs)-1].ID)
require.Nil(t, err)
l3, err := db.Label(context.Background(), specs[2].ID)
l3, _, err := db.Label(context.Background(), specs[2].ID)
require.Nil(t, err)
user := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
@ -664,7 +664,7 @@ func testLabelsChangeDetails(t *testing.T, db *Datastore) {
err = db.ApplyLabelSpecs(context.Background(), []*fleet.LabelSpec{&label})
require.Nil(t, err)
saved, err := db.Label(context.Background(), label.ID)
saved, _, err := db.Label(context.Background(), label.ID)
require.Nil(t, err)
assert.Equal(t, label.Name, saved.Name)
}
@ -781,9 +781,9 @@ func testLabelsSave(t *testing.T, db *Datastore) {
require.NoError(t, db.RecordLabelQueryExecutions(context.Background(), h1, map[uint]*bool{label.ID: ptr.Bool(true)}, time.Now(), false))
_, err = db.SaveLabel(context.Background(), label)
_, _, err = db.SaveLabel(context.Background(), label)
require.NoError(t, err)
saved, err := db.Label(context.Background(), label.ID)
saved, _, err := db.Label(context.Background(), label.ID)
require.NoError(t, err)
assert.Equal(t, label.Name, saved.Name)
assert.Equal(t, label.Description, saved.Description)
@ -1479,6 +1479,10 @@ func testAddDeleteLabelsToFromHost(t *testing.T, ds *Datastore) {
// Adding and removing labels.
err = ds.AddLabelsToHost(ctx, host1.ID, []uint{label1.ID})
require.NoError(t, err)
lbl, hids, err := ds.Label(ctx, label1.ID)
require.NoError(t, err)
require.Equal(t, label1.ID, lbl.ID)
require.ElementsMatch(t, []uint{host1.ID}, hids)
getLabelUpdatedAt := func(updatedAt *time.Time) func(q sqlx.ExtContext) error {
return func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, updatedAt, `SELECT updated_at FROM label_membership WHERE host_id = ? AND label_id = ?`, host1.ID, label1.ID)
@ -1515,6 +1519,18 @@ func testAddDeleteLabelsToFromHost(t *testing.T, ds *Datastore) {
labels, err = ds.ListLabelsForHost(ctx, host1.ID)
require.NoError(t, err)
require.Len(t, labels, 2)
err = ds.AddLabelsToHost(ctx, host2.ID, []uint{label1.ID})
require.NoError(t, err)
labels, err = ds.ListLabelsForHost(ctx, host2.ID)
require.NoError(t, err)
require.Len(t, labels, 1)
lbl, hids, err = ds.Label(ctx, label1.ID)
require.NoError(t, err)
require.Equal(t, label1.ID, lbl.ID)
require.ElementsMatch(t, []uint{host1.ID, host2.ID}, hids)
err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID, label2.ID})
require.NoError(t, err)
labels, err = ds.ListLabelsForHost(ctx, host1.ID)

View file

@ -381,19 +381,19 @@ func testTargetsHostIDsInTargets(t *testing.T, ds *Datastore) {
h6 := initHost(nil, "darwin")
// Load and record results for builtin labels.
allHosts, err := ds.Label(context.Background(), 6)
allHosts, _, err := ds.Label(context.Background(), 6)
require.NoError(t, err)
macOS, err := ds.Label(context.Background(), 7)
macOS, _, err := ds.Label(context.Background(), 7)
require.NoError(t, err)
ubuntuLinux, err := ds.Label(context.Background(), 8)
ubuntuLinux, _, err := ds.Label(context.Background(), 8)
require.NoError(t, err)
centOSLinux, err := ds.Label(context.Background(), 9)
centOSLinux, _, err := ds.Label(context.Background(), 9)
require.NoError(t, err)
msWindows, err := ds.Label(context.Background(), 10)
msWindows, _, err := ds.Label(context.Background(), 10)
require.NoError(t, err)
redHatLinux, err := ds.Label(context.Background(), 11)
redHatLinux, _, err := ds.Label(context.Background(), 11)
require.NoError(t, err)
allLinux, err := ds.Label(context.Background(), 12)
allLinux, _, err := ds.Label(context.Background(), 12)
require.NoError(t, err)
allBuiltIn := []*fleet.Label{

View file

@ -25,7 +25,7 @@ func TestUnicode(t *testing.T) {
err := ds.ApplyLabelSpecs(context.Background(), []*fleet.LabelSpec{&l1})
require.Nil(t, err)
label, err := ds.Label(context.Background(), l1.ID)
label, _, err := ds.Label(context.Background(), l1.ID)
require.Nil(t, err)
assert.Equal(t, "測試", label.Name)

View file

@ -184,9 +184,12 @@ type Datastore interface {
RemoveLabelsFromHost(ctx context.Context, hostID uint, labelIDs []uint) error
NewLabel(ctx context.Context, Label *Label, opts ...OptionalArg) (*Label, error)
SaveLabel(ctx context.Context, label *Label) (*Label, error)
// SaveLabel updates the label and returns the label and an array of host IDs
// members of this label, or an error.
SaveLabel(ctx context.Context, label *Label) (*Label, []uint, error)
DeleteLabel(ctx context.Context, name string) error
Label(ctx context.Context, lid uint) (*Label, error)
// Label returns the label and an array of host IDs members of this label, or an error.
Label(ctx context.Context, lid uint) (*Label, []uint, error)
ListLabels(ctx context.Context, filter TeamFilter, opt ListOptions) ([]*Label, error)
LabelsSummary(ctx context.Context) ([]*LabelSummary, error)
@ -270,15 +273,19 @@ type Datastore interface {
// HostIDsByOSVersion retrieves the IDs of all host matching osVersion
HostIDsByOSVersion(ctx context.Context, osVersion OSVersion, offset int, limit int) ([]uint, error)
// HostByIdentifier returns one host matching the provided identifier. Possible matches can be on
// osquery_host_identifier, node_key, UUID, or hostname.
// osquery_host_id, node_key, UUID, hardware_serial or hostname.
HostByIdentifier(ctx context.Context, identifier string) (*Host, error)
// HostLiteByIdentifier returns a host and a subset of its fields using an "identifier" string.
// The identifier string will be matched against the hostname, osquery_host_id, node_key, uuid and hardware_serial columns.
// HostLiteByIdentifier returns a host and a subset of its fields using an
// "identifier" string. The identifier string will be matched against the
// hostname, osquery_host_id, node_key, uuid and hardware_serial columns.
HostLiteByIdentifier(ctx context.Context, identifier string) (*HostLite, error)
// HostLiteByIdentifier returns a host and a subset of its fields from its id.
HostLiteByID(ctx context.Context, id uint) (*HostLite, error)
// AddHostsToTeam adds hosts to an existing team, clearing their team settings if teamID is nil.
AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []uint) error
// HostnamesByIdentifiers returns the hostnames corresponding to the provided identifiers,
// as understood by HostByIdentifier.
HostnamesByIdentifiers(ctx context.Context, identifiers []string) ([]string, error)
TotalAndUnseenHostsSince(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error)

View file

@ -9,13 +9,27 @@ import (
type ModifyLabelPayload struct {
Name *string `json:"name"`
Description *string `json:"description"`
// Hosts is the new list of host identifiers to apply for this label, only
// valid for manual labels. If it is nil (not just len() == 0, but == nil),
// then the list of hosts is not modified. If it is not nil and len == 0,
// then all members are removed.
Hosts []string `json:"hosts"`
}
type LabelPayload struct {
Name *string `json:"name"`
Query *string `json:"query"`
Platform *string `json:"platform"`
Description *string `json:"description"`
Name string `json:"name"`
// Query is the SQL query that defines the label. This defines a dynamic
// label, where the hosts that are part of the label are determined based on
// the query result. Must be empty for a manual label, must be provided for a
// dynamic one.
Query string `json:"query"`
Platform string `json:"platform"`
Description string `json:"description"`
// Hosts is the list of host identifier (serial, uuid, name, etc. as
// supported by HostByIdentifier) that are part of the label. This defines a
// manual label. Can be empty for a manual label that doesn't target any
// host. Must be empty for a dynamic label.
Hosts []string `json:"hosts"`
}
// LabelType is used to catagorize the kind of label
@ -129,7 +143,7 @@ type LabelSpec struct {
Platform string `json:"platform,omitempty"`
LabelType LabelType `json:"label_type,omitempty" db:"label_type"`
LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"`
Hosts []string `json:"hosts,omitempty"`
Hosts []string `json:"hosts"`
}
const (

View file

@ -241,11 +241,11 @@ type Service interface {
// GetLabelSpec gets the spec for the label with the given name.
GetLabelSpec(ctx context.Context, name string) (*LabelSpec, error)
NewLabel(ctx context.Context, p LabelPayload) (label *Label, err error)
ModifyLabel(ctx context.Context, id uint, payload ModifyLabelPayload) (*Label, error)
NewLabel(ctx context.Context, p LabelPayload) (label *Label, hostIDs []uint, err error)
ModifyLabel(ctx context.Context, id uint, payload ModifyLabelPayload) (*Label, []uint, error)
ListLabels(ctx context.Context, opt ListOptions) (labels []*Label, err error)
LabelsSummary(ctx context.Context) (labels []*LabelSummary, err error)
GetLabel(ctx context.Context, id uint) (label *Label, err error)
GetLabel(ctx context.Context, id uint) (label *Label, hostIDs []uint, err error)
DeleteLabel(ctx context.Context, name string) (err error)
// DeleteLabelByID is for backwards compatibility with the UI

View file

@ -135,11 +135,11 @@ type RemoveLabelsFromHostFunc func(ctx context.Context, hostID uint, labelIDs []
type NewLabelFunc func(ctx context.Context, Label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error)
type SaveLabelFunc func(ctx context.Context, label *fleet.Label) (*fleet.Label, error)
type SaveLabelFunc func(ctx context.Context, label *fleet.Label) (*fleet.Label, []uint, error)
type DeleteLabelFunc func(ctx context.Context, name string) error
type LabelFunc func(ctx context.Context, lid uint) (*fleet.Label, error)
type LabelFunc func(ctx context.Context, lid uint) (*fleet.Label, []uint, error)
type ListLabelsFunc func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Label, error)
@ -205,6 +205,8 @@ type HostLiteByIDFunc func(ctx context.Context, id uint) (*fleet.HostLite, error
type AddHostsToTeamFunc func(ctx context.Context, teamID *uint, hostIDs []uint) error
type HostnamesByIdentifiersFunc func(ctx context.Context, identifiers []string) ([]string, error)
type TotalAndUnseenHostsSinceFunc func(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error)
type DeleteHostsFunc func(ctx context.Context, ids []uint) error
@ -1189,6 +1191,9 @@ type DataStore struct {
AddHostsToTeamFunc AddHostsToTeamFunc
AddHostsToTeamFuncInvoked bool
HostnamesByIdentifiersFunc HostnamesByIdentifiersFunc
HostnamesByIdentifiersFuncInvoked bool
TotalAndUnseenHostsSinceFunc TotalAndUnseenHostsSinceFunc
TotalAndUnseenHostsSinceFuncInvoked bool
@ -2654,7 +2659,7 @@ func (s *DataStore) NewLabel(ctx context.Context, Label *fleet.Label, opts ...fl
return s.NewLabelFunc(ctx, Label, opts...)
}
func (s *DataStore) SaveLabel(ctx context.Context, label *fleet.Label) (*fleet.Label, error) {
func (s *DataStore) SaveLabel(ctx context.Context, label *fleet.Label) (*fleet.Label, []uint, error) {
s.mu.Lock()
s.SaveLabelFuncInvoked = true
s.mu.Unlock()
@ -2668,7 +2673,7 @@ func (s *DataStore) DeleteLabel(ctx context.Context, name string) error {
return s.DeleteLabelFunc(ctx, name)
}
func (s *DataStore) Label(ctx context.Context, lid uint) (*fleet.Label, error) {
func (s *DataStore) Label(ctx context.Context, lid uint) (*fleet.Label, []uint, error) {
s.mu.Lock()
s.LabelFuncInvoked = true
s.mu.Unlock()
@ -2899,6 +2904,13 @@ func (s *DataStore) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []
return s.AddHostsToTeamFunc(ctx, teamID, hostIDs)
}
func (s *DataStore) HostnamesByIdentifiers(ctx context.Context, identifiers []string) ([]string, error) {
s.mu.Lock()
s.HostnamesByIdentifiersFuncInvoked = true
s.mu.Unlock()
return s.HostnamesByIdentifiersFunc(ctx, identifiers)
}
func (s *DataStore) TotalAndUnseenHostsSince(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error) {
s.mu.Lock()
s.TotalAndUnseenHostsSinceFuncInvoked = true

View file

@ -2403,7 +2403,7 @@ func (svc *Service) validateLabelNames(ctx context.Context, action string, label
var dynamicLabels []string
for labelName, labelID := range labels {
label, err := svc.ds.Label(ctx, labelID)
label, _, err := svc.ds.Label(ctx, labelID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "load label from id")
}

View file

@ -3729,15 +3729,22 @@ func (s *integrationTestSuite) TestGetMacadminsData() {
func (s *integrationTestSuite) TestLabels() {
t := s.T()
// create some hosts to use for manual labels
hosts := s.createHosts(t, "debian", "linux", "fedora", "darwin", "darwin", "darwin")
manualHosts := hosts[:3]
lbl2Hosts := hosts[3:]
// list labels, has the built-in ones
builtinsMap := fleet.ReservedLabelNames()
var listResp listLabelsResponse
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp)
assert.True(t, len(listResp.Labels) > 0)
var builtinLbl fleet.Label
for _, lbl := range listResp.Labels {
_, ok := builtinsMap[lbl.Name]
assert.True(t, ok)
assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType)
builtinLbl = lbl.Label
}
builtInsCount := len(listResp.Labels)
require.Equal(t, builtInsCount, len(builtinsMap))
@ -3754,31 +3761,76 @@ func (s *integrationTestSuite) TestLabels() {
// create a label without name, an error
var createResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Query: ptr.String("select 1")}, http.StatusUnprocessableEntity, &createResp)
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Query: "select 1"}, http.StatusUnprocessableEntity, &createResp)
// create a label with both a query and hosts, error
res := s.Do("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1", Hosts: []string{manualHosts[0].UUID}}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of either "query" or "hosts" can be included in the request.`)
// create invalid label, conflicts with builtin name
for n := range builtinsMap {
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String(n), Query: ptr.String("select 1")}, http.StatusUnprocessableEntity, &createResp)
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: n, Query: "select 1"}, http.StatusUnprocessableEntity, &createResp)
}
// create a valid label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String(t.Name()), Query: ptr.String("select 1")}, http.StatusOK, &createResp)
// create a valid dynamic label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, t.Name(), createResp.Label.Name)
assert.Empty(t, createResp.Label.HostIDs)
lbl1 := createResp.Label.Label
// get the label
var getResp getLabelResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, lbl1.ID, getResp.Label.ID)
assert.Empty(t, getResp.Label.HostIDs)
// get a non-existing label
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID+1), nil, http.StatusNotFound, &getResp)
// modify that label
// create a valid manual label
createResp = createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name() + "manual", Hosts: []string{manualHosts[0].UUID, manualHosts[1].Hostname, *manualHosts[2].NodeKey}}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, t.Name()+"manual", createResp.Label.Name)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, createResp.Label.HostIDs)
manualLbl1 := createResp.Label.Label
// get the label
getResp = getLabelResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, manualLbl1.ID, getResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, getResp.Label.HostIDs)
assert.EqualValues(t, 3, getResp.Label.HostCount)
// create a valid empty manual label
createResp = createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_") + "manual2"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
assert.Equal(t, strings.ReplaceAll(t.Name(), "/", "_")+"manual2", createResp.Label.Name)
assert.Empty(t, createResp.Label.HostIDs)
manualLbl2 := createResp.Label.Label
// get the label
getResp = getLabelResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, manualLbl2.ID, getResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType)
assert.Empty(t, getResp.Label.HostIDs)
assert.EqualValues(t, 0, getResp.Label.HostCount)
// get a non-existing label
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), nil, http.StatusNotFound, &getResp)
// modify dynamic label lbl1
var modResp modifyLabelResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(t.Name() + "zzz")}, http.StatusOK, &modResp)
assert.Equal(t, lbl1.ID, modResp.Label.ID)
assert.Empty(t, modResp.Label.HostIDs)
assert.NotEqual(t, lbl1.Name, modResp.Label.Name)
// attempt to modify a label to a reserved name
@ -3787,59 +3839,105 @@ func (s *integrationTestSuite) TestLabels() {
}
// modify a non-existing label
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID+1), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusNotFound, &modResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusNotFound, &modResp)
// modify a built-in label
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", builtinLbl.ID), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "cannot modify built-in label")
// modify manual label 1 without modifying its hosts
modResp = modifyLabelResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String("modified_manual_label1")}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl1.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 3, modResp.Label.HostCount)
// modify manual label 2 adding some hosts
modResp = modifyLabelResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{manualHosts[0].UUID}}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType)
assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType)
assert.ElementsMatch(t, []uint{manualHosts[0].ID}, modResp.Label.HostIDs)
assert.EqualValues(t, 1, modResp.Label.HostCount)
// modify manual label 2 clearing its hosts
modResp = modifyLabelResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{}, Description: ptr.String("desc")}, http.StatusOK, &modResp)
assert.Equal(t, manualLbl2.ID, modResp.Label.ID)
assert.Equal(t, "desc", modResp.Label.Description)
assert.Empty(t, modResp.Label.HostIDs)
assert.EqualValues(t, 0, modResp.Label.HostCount)
// list labels
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1))
assert.Len(t, listResp.Labels, builtInsCount+1)
dynamicLabels := []fleet.Label{lbl1}
manualLabels := []fleet.Label{manualLbl1, manualLbl2}
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(100))
assert.Len(t, listResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels))
// labels summary
s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp)
assert.Len(t, summaryResp.Labels, builtInsCount+1)
assert.Len(t, summaryResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels))
// next page is empty
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "page", "1")
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", "100", "page", "1")
assert.Len(t, listResp.Labels, 0)
// list labels with invalid query params
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "order_key", "id", "after", "1")
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "query", "no match query for this endpoint")
// create another label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String(strings.ReplaceAll(t.Name(), "/", "_")), Query: ptr.String("select 1")}, http.StatusOK, &createResp)
// create another dynamic label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_"), Query: "select 1"}, http.StatusOK, &createResp)
assert.NotZero(t, createResp.Label.ID)
lbl2 := createResp.Label.Label
dynamicLabels = append(dynamicLabels, lbl2)
require.Len(t, dynamicLabels, 2) // to make linter happy (dynamicLabels is not used past this point)
// create hosts and add them to that label
hosts := s.createHosts(t, "darwin", "darwin", "darwin")
for _, h := range hosts {
// add lbl2 hosts to that label
for _, h := range lbl2Hosts {
err := s.ds.RecordLabelQueryExecutions(context.Background(), h, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
}
// list hosts in label
// list hosts in dynamic label lbl2
var listHostsResp listHostsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp)
assert.Len(t, listHostsResp.Hosts, len(hosts))
assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts))
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id", "after", fmt.Sprintf("%d", hosts[0].ID))
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id", "after", fmt.Sprintf("%d", lbl2Hosts[0].ID))
assert.Len(t, listHostsResp.Hosts, 2)
assert.Equal(t, hosts[1].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, hosts[2].ID, listHostsResp.Hosts[1].ID)
assert.Equal(t, lbl2Hosts[1].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, lbl2Hosts[2].ID, listHostsResp.Hosts[1].ID)
// list hosts in label ordered by display_name
// list hosts in manual label 1
listHostsResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl1.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id")
assert.Len(t, listHostsResp.Hosts, manualLbl1.HostCount)
assert.Equal(t, manualHosts[0].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, manualHosts[1].ID, listHostsResp.Hosts[1].ID)
assert.Equal(t, manualHosts[2].ID, listHostsResp.Hosts[2].ID)
// list hosts in manual label 2
listHostsResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id")
assert.Len(t, listHostsResp.Hosts, 0)
// list hosts in dynamic label 2 searching by display_name
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "display_name", "order_direction", "desc")
assert.Len(t, listHostsResp.Hosts, len(hosts))
assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts))
// first in the list is the last one, as the names are ordered with the index
// of creation, and vice-versa
assert.Equal(t, hosts[len(hosts)-1].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, hosts[0].ID, listHostsResp.Hosts[len(hosts)-1].ID)
assert.Equal(t, lbl2Hosts[len(lbl2Hosts)-1].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[len(lbl2Hosts)-1].ID)
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
`INSERT INTO host_emails (host_id, email, source) VALUES (?, ?, ?)`,
hosts[0].ID, "a@b.c", "src1")
lbl2Hosts[0].ID, "a@b.c", "src1")
return err
})
@ -3847,17 +3945,17 @@ func (s *integrationTestSuite) TestLabels() {
// list hosts in label searching by email address
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", "a@b.c")
assert.Len(t, listHostsResp.Hosts, 1)
assert.Equal(t, hosts[0].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID)
// list hosts in label searching by email address with leading/trailing whitespace
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", " a@b.c ")
assert.Len(t, listHostsResp.Hosts, 1)
assert.Equal(t, hosts[0].ID, listHostsResp.Hosts[0].ID)
assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID)
// count hosts in label order by display_name
var countResp countHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl2.ID), "order_key", "display_name", "order_direction", "desc")
assert.Equal(t, len(hosts), countResp.Count)
assert.Equal(t, len(lbl2Hosts), countResp.Count)
// lists hosts in label without hosts
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl1.ID), nil, http.StatusOK, &listHostsResp)
@ -3872,7 +3970,7 @@ func (s *integrationTestSuite) TestLabels() {
assert.Len(t, listHostsResp.Hosts, 0)
// set MDM information on a host
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), hosts[0].ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, ""))
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), lbl2Hosts[0].ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, ""))
var mdmID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &mdmID,
@ -3905,6 +4003,12 @@ func (s *integrationTestSuite) TestLabels() {
// delete a non-existing label by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusNotFound, &delResp)
// delete a manual label by id
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", manualLbl1.ID), nil, http.StatusOK, &delIDResp)
// delete a manual label by name
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(manualLbl2.Name)), nil, http.StatusOK, &delResp)
// list labels, only the built-ins remain
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1))
assert.Len(t, listResp.Labels, builtInsCount)

View file

@ -158,12 +158,12 @@ func (s *integrationMDMTestSuite) TestAppleDDMBatchUpload() {
require.Equal(t, "darwin", resp.Profiles[1].Platform)
var createResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String("label_1"), Query: ptr.String("select 1")}, http.StatusOK, &createResp)
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label_1", Query: "select 1"}, http.StatusOK, &createResp)
require.NotZero(t, createResp.Label.ID)
require.Equal(t, "label_1", createResp.Label.Name)
lbl1 := createResp.Label.Label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String("label_2"), Query: ptr.String("select 1")}, http.StatusOK, &createResp)
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label_2", Query: "select 1"}, http.StatusOK, &createResp)
require.NotZero(t, createResp.Label.ID)
require.Equal(t, "label_2", createResp.Label.Name)
lbl2 := createResp.Label.Label

View file

@ -4378,8 +4378,8 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() {
clr := createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{
LabelPayload: fleet.LabelPayload{
Name: ptr.String("foo"),
Query: ptr.String("SELECT 1;"),
Name: "foo",
Query: "SELECT 1;",
},
}, http.StatusOK, &clr)

View file

@ -28,12 +28,12 @@ func (r createLabelResponse) error() error { return r.Err }
func createLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*createLabelRequest)
label, err := svc.NewLabel(ctx, req.LabelPayload)
label, hostIDs, err := svc.NewLabel(ctx, req.LabelPayload)
if err != nil {
return createLabelResponse{Err: err}, nil
}
labelResp, err := labelResponseForLabel(ctx, svc, label)
labelResp, err := labelResponseForLabel(ctx, svc, label, hostIDs)
if err != nil {
return createLabelResponse{Err: err}, nil
}
@ -41,42 +41,78 @@ func createLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
return createLabelResponse{Label: *labelResp}, nil
}
func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.Label, error) {
func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.Label, []uint, error) {
if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionWrite); err != nil {
return nil, err
return nil, nil, err
}
label := &fleet.Label{}
if p.Name == nil {
return nil, fleet.NewInvalidArgumentError("name", "missing required argument")
}
label.Name = *p.Name
if p.Query == nil {
return nil, fleet.NewInvalidArgumentError("query", "missing required argument")
}
label.Query = *p.Query
if p.Platform != nil {
label.Platform = *p.Platform
label := &fleet.Label{
LabelType: fleet.LabelTypeRegular,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
}
if p.Description != nil {
label.Description = *p.Description
if p.Name == "" {
return nil, nil, fleet.NewInvalidArgumentError("name", "missing required argument")
}
label.Name = p.Name
if p.Query != "" && len(p.Hosts) > 0 {
return nil, nil, fleet.NewInvalidArgumentError("query", `Only one of either "query" or "hosts" can be included in the request.`)
}
label.Query = p.Query
if p.Query == "" {
label.LabelMembershipType = fleet.LabelMembershipTypeManual
}
label.Platform = p.Platform
label.Description = p.Description
for name := range fleet.ReservedLabelNames() {
if label.Name == name {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("cannot add label '%s' because it conflicts with the name of a built-in label", name))
return nil, nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("cannot add label '%s' because it conflicts with the name of a built-in label", name))
}
}
label, err := svc.ds.NewLabel(ctx, label)
if err != nil {
return nil, err
// if membership type is manual, must use ApplyLabelSpecs (as NewLabel does
// not create label memberships), otherwise NewLabel works for dynamic
// membership. Must resolve the host identifiers to hostname so that
// ApplySpecs can be used.
var hostIDs []uint
if label.LabelMembershipType == fleet.LabelMembershipTypeManual {
spec := fleet.LabelSpec{
Name: label.Name,
Description: label.Description,
Query: label.Query,
Platform: label.Platform,
LabelType: label.LabelType,
LabelMembershipType: label.LabelMembershipType,
}
hostnames, err := svc.ds.HostnamesByIdentifiers(ctx, p.Hosts)
if err != nil {
return nil, nil, err
}
spec.Hosts = hostnames
if err := svc.ds.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{&spec}); err != nil {
return nil, nil, err
}
// must reload it to get the id, and the host IDs
lblIDsByName, err := svc.ds.LabelIDsByName(ctx, []string{label.Name})
if err != nil {
return nil, nil, err
}
label, hostIDs, err = svc.ds.Label(ctx, lblIDsByName[label.Name])
if err != nil {
return nil, nil, err
}
} else {
newLbl, err := svc.ds.NewLabel(ctx, label)
if err != nil {
return nil, nil, err
}
label = newLbl
}
return label, nil
return label, hostIDs, nil
}
////////////////////////////////////////////////////////////////////////////////
@ -97,12 +133,12 @@ func (r modifyLabelResponse) error() error { return r.Err }
func modifyLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*modifyLabelRequest)
label, err := svc.ModifyLabel(ctx, req.ID, req.ModifyLabelPayload)
label, hostIDs, err := svc.ModifyLabel(ctx, req.ID, req.ModifyLabelPayload)
if err != nil {
return modifyLabelResponse{Err: err}, nil
}
labelResp, err := labelResponseForLabel(ctx, svc, label)
labelResp, err := labelResponseForLabel(ctx, svc, label, hostIDs)
if err != nil {
return modifyLabelResponse{Err: err}, nil
}
@ -110,23 +146,23 @@ func modifyLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
return modifyLabelResponse{Label: *labelResp}, err
}
func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.ModifyLabelPayload) (*fleet.Label, error) {
func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.ModifyLabelPayload) (*fleet.Label, []uint, error) {
if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionWrite); err != nil {
return nil, err
return nil, nil, err
}
label, err := svc.ds.Label(ctx, id)
label, _, err := svc.ds.Label(ctx, id)
if err != nil {
return nil, err
return nil, nil, err
}
if label.LabelType == fleet.LabelTypeBuiltIn {
return nil, fleet.NewInvalidArgumentError("label_type", fmt.Sprintf("cannot modify built-in label '%s'", label.Name))
return nil, nil, fleet.NewInvalidArgumentError("label_type", fmt.Sprintf("cannot modify built-in label '%s'", label.Name))
}
if payload.Name != nil {
// Check if the new name is a reserved label name
for name := range fleet.ReservedLabelNames() {
if *payload.Name == name {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("cannot rename label to '%s' because it conflicts with the name of a built-in label", name))
return nil, nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("cannot rename label to '%s' because it conflicts with the name of a built-in label", name))
}
}
label.Name = *payload.Name
@ -134,6 +170,36 @@ func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.Modi
if payload.Description != nil {
label.Description = *payload.Description
}
if len(payload.Hosts) > 0 && label.LabelMembershipType != fleet.LabelMembershipTypeManual {
return nil, nil, fleet.NewInvalidArgumentError("hosts", "cannot provide a list of hosts for a dynamic label")
}
// if membership type is manual and the Hosts membership is provided, must
// use ApplyLabelSpecs (as SaveLabel does not update label memberships),
// otherwise SaveLabel works for dynamic membership. Must resolve the host
// identifiers to hostname so that ApplySpecs can be used (it expects only
// hostnames).
if label.LabelMembershipType == fleet.LabelMembershipTypeManual && payload.Hosts != nil {
spec := fleet.LabelSpec{
Name: label.Name,
Description: label.Description,
Query: label.Query,
Platform: label.Platform,
LabelType: label.LabelType,
LabelMembershipType: label.LabelMembershipType,
}
hostnames, err := svc.ds.HostnamesByIdentifiers(ctx, payload.Hosts)
if err != nil {
return nil, nil, err
}
spec.Hosts = hostnames
if err := svc.ds.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{&spec}); err != nil {
return nil, nil, err
}
// must reload it to get the host counts information
return svc.ds.Label(ctx, id)
}
return svc.ds.SaveLabel(ctx, label)
}
@ -161,20 +227,20 @@ func (r getLabelResponse) error() error { return r.Err }
func getLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*getLabelRequest)
label, err := svc.GetLabel(ctx, req.ID)
label, hostIDs, err := svc.GetLabel(ctx, req.ID)
if err != nil {
return getLabelResponse{Err: err}, nil
}
resp, err := labelResponseForLabel(ctx, svc, label)
resp, err := labelResponseForLabel(ctx, svc, label, hostIDs)
if err != nil {
return getLabelResponse{Err: err}, nil
}
return getLabelResponse{Label: *resp}, nil
}
func (svc *Service) GetLabel(ctx context.Context, id uint) (*fleet.Label, error) {
func (svc *Service) GetLabel(ctx context.Context, id uint) (*fleet.Label, []uint, error) {
if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil {
return nil, err
return nil, nil, err
}
return svc.ds.Label(ctx, id)
@ -205,7 +271,7 @@ func listLabelsEndpoint(ctx context.Context, request interface{}, svc fleet.Serv
resp := listLabelsResponse{}
for _, label := range labels {
labelResp, err := labelResponseForLabel(ctx, svc, label)
labelResp, err := labelResponseForLabel(ctx, svc, label, nil)
if err != nil {
return listLabelsResponse{Err: err}, nil
}
@ -224,14 +290,21 @@ func (svc *Service) ListLabels(ctx context.Context, opt fleet.ListOptions) ([]*f
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
// TODO(mna): ListLabels doesn't currently return the hostIDs members of the
// label, the quick approach would be an N+1 queries endpoint. Leaving like
// that for now because we're in a hurry before merge freeze but the solution
// would probably be to do it in 2 queries : grab all label IDs from the
// list, then select hostID+labelID tuples in one query (where labelID IN
// <list of ids>)and fill the hostIDs per label.
return svc.ds.ListLabels(ctx, filter, opt)
}
func labelResponseForLabel(ctx context.Context, svc fleet.Service, label *fleet.Label) (*labelResponse, error) {
func labelResponseForLabel(ctx context.Context, svc fleet.Service, label *fleet.Label, hostIDs []uint) (*labelResponse, error) {
return &labelResponse{
Label: *label,
DisplayText: label.Name,
Count: label.HostCount,
HostIDs: hostIDs,
}, nil
}
@ -374,7 +447,7 @@ func (svc *Service) DeleteLabelByID(ctx context.Context, id uint) error {
return err
}
label, err := svc.ds.Label(ctx, id)
label, _, err := svc.ds.Label(ctx, id)
if err != nil {
return err
}

View file

@ -21,8 +21,8 @@ func TestLabelsAuth(t *testing.T) {
ds.NewLabelFunc = func(ctx context.Context, lbl *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) {
return lbl, nil
}
ds.SaveLabelFunc = func(ctx context.Context, lbl *fleet.Label) (*fleet.Label, error) {
return lbl, nil
ds.SaveLabelFunc = func(ctx context.Context, lbl *fleet.Label) (*fleet.Label, []uint, error) {
return lbl, nil, nil
}
ds.DeleteLabelFunc = func(ctx context.Context, nm string) error {
return nil
@ -30,8 +30,8 @@ func TestLabelsAuth(t *testing.T) {
ds.ApplyLabelSpecsFunc = func(ctx context.Context, specs []*fleet.LabelSpec) error {
return nil
}
ds.LabelFunc = func(ctx context.Context, id uint) (*fleet.Label, error) {
return &fleet.Label{}, nil
ds.LabelFunc = func(ctx context.Context, id uint) (*fleet.Label, []uint, error) {
return &fleet.Label{}, nil, nil
}
ds.ListLabelsFunc = func(ctx context.Context, filter fleet.TeamFilter, opts fleet.ListOptions) ([]*fleet.Label, error) {
return nil, nil
@ -90,16 +90,16 @@ func TestLabelsAuth(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
_, err := svc.NewLabel(ctx, fleet.LabelPayload{Name: ptr.String(t.Name()), Query: ptr.String(`SELECT 1`)})
_, _, err := svc.NewLabel(ctx, fleet.LabelPayload{Name: t.Name(), Query: `SELECT 1`})
checkAuthErr(t, tt.shouldFailWrite, err)
_, err = svc.ModifyLabel(ctx, 1, fleet.ModifyLabelPayload{})
_, _, err = svc.ModifyLabel(ctx, 1, fleet.ModifyLabelPayload{})
checkAuthErr(t, tt.shouldFailWrite, err)
err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{})
checkAuthErr(t, tt.shouldFailWrite, err)
_, err = svc.GetLabel(ctx, 1)
_, _, err = svc.GetLabel(ctx, 1)
checkAuthErr(t, tt.shouldFailRead, err)
_, err = svc.GetLabelSpecs(ctx)
@ -155,7 +155,7 @@ func testLabelsGetLabel(t *testing.T, ds *mysql.Datastore) {
assert.Nil(t, err)
assert.NotZero(t, label.ID)
labelVerify, err := svc.GetLabel(test.UserContext(ctx, test.UserAdmin), label.ID)
labelVerify, _, err := svc.GetLabel(test.UserContext(ctx, test.UserAdmin), label.ID)
assert.Nil(t, err)
assert.Equal(t, label.ID, labelVerify.ID)
}

View file

@ -8,17 +8,17 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
)
func (mw metricsMiddleware) ModifyLabel(ctx context.Context, id uint, p fleet.ModifyLabelPayload) (*fleet.Label, error) {
func (mw metricsMiddleware) ModifyLabel(ctx context.Context, id uint, p fleet.ModifyLabelPayload) (*fleet.Label, []uint, error) {
var (
lic *fleet.Label
err error
lic *fleet.Label
hids []uint
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "ModifyLabel", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
lic, err = mw.Service.ModifyLabel(ctx, id, p)
return lic, err
lic, hids, err = mw.Service.ModifyLabel(ctx, id, p)
return lic, hids, err
}