mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
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:
parent
de94299b65
commit
2fc4e520b8
59 changed files with 1607 additions and 726 deletions
1
changes/17899-add-manual-labels-api
Normal file
1
changes/17899-add-manual-labels-api
Normal 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.
|
||||
1
changes/issue-17898-new-manual-lables
Normal file
1
changes/issue-17898-new-manual-lables
Normal file
|
|
@ -0,0 +1 @@
|
|||
- implement manual labels in fleet UI
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"}))
|
||||
|
|
|
|||
31
frontend/__mocks__/labelsMock.ts
Normal file
31
frontend/__mocks__/labelsMock.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
.label-page {
|
||||
&__sandboxMode {
|
||||
margin-top: 70px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./LabelPage";
|
||||
139
frontend/pages/labels/EditLabelPage/EditLabelPage.tsx
Normal file
139
frontend/pages/labels/EditLabelPage/EditLabelPage.tsx
Normal 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;
|
||||
5
frontend/pages/labels/EditLabelPage/_styles.scss
Normal file
5
frontend/pages/labels/EditLabelPage/_styles.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.edit-label-page {
|
||||
h1 {
|
||||
margin-bottom: $pad-xxlarge;
|
||||
}
|
||||
}
|
||||
1
frontend/pages/labels/EditLabelPage/index.ts
Normal file
1
frontend/pages/labels/EditLabelPage/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./EditLabelPage";
|
||||
|
|
@ -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;
|
||||
1
frontend/pages/labels/NewLabelPage/DynamicLabel/index.ts
Normal file
1
frontend/pages/labels/NewLabelPage/DynamicLabel/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./DynamicLabel";
|
||||
|
|
@ -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;
|
||||
1
frontend/pages/labels/NewLabelPage/ManualLabel/index.ts
Normal file
1
frontend/pages/labels/NewLabelPage/ManualLabel/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ManualLabel";
|
||||
118
frontend/pages/labels/NewLabelPage/NewLabelPage.tsx
Normal file
118
frontend/pages/labels/NewLabelPage/NewLabelPage.tsx
Normal 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;
|
||||
15
frontend/pages/labels/NewLabelPage/_styles.scss
Normal file
15
frontend/pages/labels/NewLabelPage/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/pages/labels/NewLabelPage/index.ts
Normal file
1
frontend/pages/labels/NewLabelPage/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./NewLabelPage";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./DynamicLabelForm";
|
||||
91
frontend/pages/labels/components/LabelForm/LabelForm.tsx
Normal file
91
frontend/pages/labels/components/LabelForm/LabelForm.tsx
Normal 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;
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
&__label-title,
|
||||
&__label-description {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__label-platform {
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ManualLabelForm";
|
||||
|
|
@ -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;
|
||||
1
frontend/pages/labels/components/PlatformField/index.ts
Normal file
1
frontend/pages/labels/components/PlatformField/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PlatformField";
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue