mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
For #26649 # Checklist for submitter - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. ## Details This PR adds the ability to select labels when saving or editing a query in the UI, so that the query will only target hosts with those labels. It follows the API design from https://github.com/fleetdm/fleet/pull/26589, utilizing the `labels_include_any` field. The expectation is that when creating or updating a query, `labels_include_any` is an array of label names, and when fetching a single query, `labels_include_any` is an array of objects with a `name` and an `id` key. As part of this work the `TargetLabelSelector` component is updated to allow it to show a message in place of the dropdown when there are no custom options (e.g. "include any", "include all", "exclude any") to choose from.
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import React, { useState, useEffect, useCallback, useContext } from "react";
|
|
import { useQuery } from "react-query";
|
|
|
|
import { pull, size } from "lodash";
|
|
|
|
import { AppContext } from "context/app";
|
|
|
|
import useDeepEffect from "hooks/useDeepEffect";
|
|
|
|
import {
|
|
FREQUENCY_DROPDOWN_OPTIONS,
|
|
LOGGING_TYPE_OPTIONS,
|
|
MIN_OSQUERY_VERSION_OPTIONS,
|
|
SCHEDULE_PLATFORM_DROPDOWN_OPTIONS,
|
|
DEFAULT_USE_QUERY_OPTIONS,
|
|
} from "utilities/constants";
|
|
|
|
import { CommaSeparatedPlatformString } from "interfaces/platform";
|
|
import {
|
|
ICreateQueryRequestBody,
|
|
ISchedulableQuery,
|
|
QueryLoggingOption,
|
|
} from "interfaces/schedulable_query";
|
|
|
|
import Checkbox from "components/forms/fields/Checkbox";
|
|
// @ts-ignore
|
|
import InputField from "components/forms/fields/InputField";
|
|
// @ts-ignore
|
|
import Dropdown from "components/forms/fields/Dropdown";
|
|
import Slider from "components/forms/fields/Slider";
|
|
import TooltipWrapper from "components/TooltipWrapper";
|
|
import Icon from "components/Icon";
|
|
import Button from "components/buttons/Button";
|
|
import Modal from "components/Modal";
|
|
import RevealButton from "components/buttons/RevealButton";
|
|
import LogDestinationIndicator from "components/LogDestinationIndicator";
|
|
import TargetLabelSelector from "components/TargetLabelSelector";
|
|
import labelsAPI, {
|
|
getCustomLabels,
|
|
ILabelsSummaryResponse,
|
|
} from "services/entities/labels";
|
|
|
|
import DiscardDataOption from "../DiscardDataOption";
|
|
|
|
const baseClass = "save-query-modal";
|
|
export interface ISaveQueryModalProps {
|
|
queryValue: string;
|
|
apiTeamIdForQuery?: number; // query will be global if omitted
|
|
isLoading: boolean;
|
|
saveQuery: (formData: ICreateQueryRequestBody) => void;
|
|
toggleSaveQueryModal: () => void;
|
|
backendValidators: { [key: string]: string };
|
|
existingQuery?: ISchedulableQuery;
|
|
queryReportsDisabled?: boolean;
|
|
}
|
|
|
|
const validateQueryName = (name: string) => {
|
|
const errors: { [key: string]: string } = {};
|
|
|
|
if (!name) {
|
|
errors.name = "Query name must be present";
|
|
}
|
|
|
|
const valid = !size(errors);
|
|
return { valid, errors };
|
|
};
|
|
|
|
const SaveQueryModal = ({
|
|
queryValue,
|
|
apiTeamIdForQuery,
|
|
isLoading,
|
|
saveQuery,
|
|
toggleSaveQueryModal,
|
|
backendValidators,
|
|
existingQuery,
|
|
queryReportsDisabled,
|
|
}: ISaveQueryModalProps): JSX.Element => {
|
|
const { config, isPremiumTier } = useContext(AppContext);
|
|
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [selectedFrequency, setSelectedFrequency] = useState(
|
|
existingQuery?.interval ?? 3600
|
|
);
|
|
const [
|
|
selectedPlatformOptions,
|
|
setSelectedPlatformOptions,
|
|
] = useState<CommaSeparatedPlatformString>(existingQuery?.platform ?? "");
|
|
const [
|
|
selectedMinOsqueryVersionOptions,
|
|
setSelectedMinOsqueryVersionOptions,
|
|
] = useState(existingQuery?.min_osquery_version ?? "");
|
|
const [
|
|
selectedLoggingType,
|
|
setSelectedLoggingType,
|
|
] = useState<QueryLoggingOption>(existingQuery?.logging ?? "snapshot");
|
|
const [observerCanRun, setObserverCanRun] = useState(false);
|
|
const [automationsEnabled, setAutomationsEnabled] = useState(false);
|
|
const [selectedTargetType, setSelectedTargetType] = useState("All hosts");
|
|
const [selectedLabels, setSelectedLabels] = useState({});
|
|
const [discardData, setDiscardData] = useState(false);
|
|
const [errors, setErrors] = useState<{ [key: string]: string }>(
|
|
backendValidators
|
|
);
|
|
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
|
|
|
const {
|
|
data: { labels } = { labels: [] },
|
|
isFetching: isFetchingLabels,
|
|
} = useQuery<ILabelsSummaryResponse, Error>(
|
|
["custom_labels"],
|
|
() => labelsAPI.summary(),
|
|
{
|
|
...DEFAULT_USE_QUERY_OPTIONS,
|
|
enabled: isPremiumTier,
|
|
staleTime: 10000,
|
|
select: (res) => ({ labels: getCustomLabels(res.labels) }),
|
|
}
|
|
);
|
|
|
|
const onSelectLabel = ({
|
|
name: labelName,
|
|
value,
|
|
}: {
|
|
name: string;
|
|
value: boolean;
|
|
}) => {
|
|
setSelectedLabels({
|
|
...selectedLabels,
|
|
[labelName]: value,
|
|
});
|
|
};
|
|
|
|
const toggleAdvancedOptions = () => {
|
|
setShowAdvancedOptions(!showAdvancedOptions);
|
|
};
|
|
|
|
useDeepEffect(() => {
|
|
if (name) {
|
|
setErrors({});
|
|
}
|
|
}, [name]);
|
|
|
|
useEffect(() => {
|
|
setErrors(backendValidators);
|
|
}, [backendValidators]);
|
|
|
|
// Disable saving if "Custom" targeting is selected, but no labels are selected.
|
|
const canSave =
|
|
selectedTargetType === "All hosts" ||
|
|
Object.entries(selectedLabels).some(([, value]) => {
|
|
return value;
|
|
});
|
|
|
|
const onClickSaveQuery = (evt: React.MouseEvent<HTMLFormElement>) => {
|
|
evt.preventDefault();
|
|
|
|
const trimmedName = name.trim();
|
|
|
|
const { valid, errors: newErrors } = validateQueryName(trimmedName);
|
|
setErrors({
|
|
...errors,
|
|
...newErrors,
|
|
});
|
|
setName(trimmedName);
|
|
|
|
if (valid) {
|
|
saveQuery({
|
|
// from modal fields
|
|
name: trimmedName,
|
|
description,
|
|
interval: selectedFrequency,
|
|
observer_can_run: observerCanRun,
|
|
automations_enabled: automationsEnabled,
|
|
discard_data: discardData,
|
|
platform: selectedPlatformOptions,
|
|
min_osquery_version: selectedMinOsqueryVersionOptions,
|
|
logging: selectedLoggingType,
|
|
// from previous New query page
|
|
query: queryValue,
|
|
// from doubly previous ManageQueriesPage
|
|
team_id: apiTeamIdForQuery,
|
|
labels_include_any:
|
|
selectedTargetType === "Custom"
|
|
? Object.entries(selectedLabels)
|
|
.filter(([, selected]) => selected)
|
|
.map(([labelName]) => labelName)
|
|
: [],
|
|
});
|
|
}
|
|
};
|
|
|
|
const onChangeSelectPlatformOptions = useCallback(
|
|
(values: string) => {
|
|
const valArray = values.split(",");
|
|
|
|
// Remove All if another OS is chosen
|
|
// else if Remove OS if All is chosen
|
|
if (valArray.indexOf("") === 0 && valArray.length > 1) {
|
|
// TODO - inmprove type safety of all 3 options
|
|
setSelectedPlatformOptions(
|
|
pull(valArray, "").join(",") as CommaSeparatedPlatformString
|
|
);
|
|
} else if (valArray.length > 1 && valArray.indexOf("") > -1) {
|
|
setSelectedPlatformOptions("");
|
|
} else {
|
|
setSelectedPlatformOptions(values as CommaSeparatedPlatformString);
|
|
}
|
|
},
|
|
[setSelectedPlatformOptions]
|
|
);
|
|
|
|
return (
|
|
<Modal title="Save query" onExit={toggleSaveQueryModal}>
|
|
<form
|
|
onSubmit={onClickSaveQuery}
|
|
className={baseClass}
|
|
autoComplete="off"
|
|
>
|
|
<InputField
|
|
name="name"
|
|
onChange={(value: string) => setName(value)}
|
|
onBlur={() => {
|
|
setName(name.trim());
|
|
}}
|
|
value={name}
|
|
error={errors.name}
|
|
inputClassName={`${baseClass}__name`}
|
|
label="Name"
|
|
autofocus
|
|
ignore1password
|
|
/>
|
|
<InputField
|
|
name="description"
|
|
onChange={(value: string) => setDescription(value)}
|
|
value={description}
|
|
inputClassName={`${baseClass}__description`}
|
|
label="Description"
|
|
type="textarea"
|
|
helpText="What information does your query reveal? (optional)"
|
|
/>
|
|
<Dropdown
|
|
searchable={false}
|
|
options={FREQUENCY_DROPDOWN_OPTIONS}
|
|
onChange={(value: number) => {
|
|
setSelectedFrequency(value);
|
|
}}
|
|
placeholder="Every hour"
|
|
value={selectedFrequency}
|
|
label="Frequency"
|
|
wrapperClassName={`${baseClass}__form-field form-field--frequency`}
|
|
helpText="This is how often your query collects data."
|
|
/>
|
|
<Checkbox
|
|
name="observerCanRun"
|
|
onChange={setObserverCanRun}
|
|
value={observerCanRun}
|
|
wrapperClassName="observer-can-run-wrapper"
|
|
helpText="Users with the Observer role will be able to run this query as a live query."
|
|
>
|
|
Observers can run
|
|
</Checkbox>
|
|
<Slider
|
|
onChange={() => setAutomationsEnabled(!automationsEnabled)}
|
|
value={automationsEnabled}
|
|
activeText={
|
|
<>
|
|
Automations on
|
|
{selectedFrequency === 0 && (
|
|
<TooltipWrapper
|
|
tipContent={
|
|
<>
|
|
Automations and reporting will be paused <br />
|
|
for this query until a frequency is set.
|
|
</>
|
|
}
|
|
position="right"
|
|
tipOffset={9}
|
|
showArrow
|
|
underline={false}
|
|
>
|
|
<Icon name="warning" />
|
|
</TooltipWrapper>
|
|
)}
|
|
</>
|
|
}
|
|
inactiveText="Automations off"
|
|
helpText={
|
|
<>
|
|
Historical results will {!automationsEnabled ? "not " : ""}be sent
|
|
to your log destination:{" "}
|
|
<b>
|
|
<LogDestinationIndicator
|
|
logDestination={config?.logging.result.plugin || ""}
|
|
excludeTooltip
|
|
/>
|
|
</b>
|
|
.
|
|
</>
|
|
}
|
|
/>
|
|
{isPremiumTier && (
|
|
<TargetLabelSelector
|
|
selectedTargetType={selectedTargetType}
|
|
selectedLabels={selectedLabels}
|
|
className={`${baseClass}__target`}
|
|
onSelectTargetType={setSelectedTargetType}
|
|
onSelectLabel={onSelectLabel}
|
|
labels={labels || []}
|
|
customHelpText={
|
|
<span className="form-field__help-text">
|
|
Query will target hosts that <b>have any</b> of these labels:
|
|
</span>
|
|
}
|
|
/>
|
|
)}
|
|
<RevealButton
|
|
isShowing={showAdvancedOptions}
|
|
className="advanced-options-toggle"
|
|
hideText="Hide advanced options"
|
|
showText="Show advanced options"
|
|
caretPosition="after"
|
|
onClick={toggleAdvancedOptions}
|
|
/>
|
|
{showAdvancedOptions && (
|
|
<>
|
|
<Dropdown
|
|
options={SCHEDULE_PLATFORM_DROPDOWN_OPTIONS}
|
|
placeholder="Select"
|
|
label="Platforms"
|
|
onChange={onChangeSelectPlatformOptions}
|
|
value={selectedPlatformOptions}
|
|
multi
|
|
wrapperClassName={`${baseClass}__form-field form-field--platform`}
|
|
helpText="By default, your query collects data on all compatible platforms."
|
|
/>
|
|
<Dropdown
|
|
options={MIN_OSQUERY_VERSION_OPTIONS}
|
|
onChange={setSelectedMinOsqueryVersionOptions}
|
|
placeholder="Select"
|
|
value={selectedMinOsqueryVersionOptions}
|
|
label="Minimum osquery version"
|
|
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--osquer-vers`}
|
|
/>
|
|
<Dropdown
|
|
options={LOGGING_TYPE_OPTIONS}
|
|
onChange={setSelectedLoggingType}
|
|
placeholder="Select"
|
|
value={selectedLoggingType}
|
|
label="Logging"
|
|
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--logging`}
|
|
/>
|
|
{queryReportsDisabled !== undefined && (
|
|
<DiscardDataOption
|
|
{...{
|
|
queryReportsDisabled,
|
|
selectedLoggingType,
|
|
discardData,
|
|
setDiscardData,
|
|
breakHelpText: true,
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
<div className="modal-cta-wrap">
|
|
<Button
|
|
type="submit"
|
|
variant="brand"
|
|
className="save-query-loading"
|
|
isLoading={isLoading || isFetchingLabels}
|
|
disabled={!canSave}
|
|
>
|
|
Save
|
|
</Button>
|
|
<Button onClick={toggleSaveQueryModal} variant="inverse">
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default SaveQueryModal;
|