Fleet UI: Add enable automations slider to query forms (#23562)

This commit is contained in:
RachelElysia
2024-11-13 09:32:59 -05:00
committed by GitHub
parent e5febe960d
commit 1fed4759d8
13 changed files with 316 additions and 19 deletions
+1
View File
@@ -0,0 +1 @@
- Creating a query allow users to turn on/off automations while being transparent of the current log destination
@@ -8,9 +8,10 @@ import { IFormFieldProps } from "components/forms/FormField/FormField";
interface ISliderProps {
onChange: () => void;
value: boolean;
inactiveText: string;
activeText: string;
inactiveText: JSX.Element | string;
activeText: JSX.Element | string;
className?: string;
helpText?: JSX.Element | string;
}
const baseClass = "fleet-slider";
@@ -24,7 +24,7 @@
&__wrapper {
display: flex;
align-items: center;
height: 40px;
height: 24px; // Noticeable with help text below slider
}
&__dot {
@@ -40,10 +40,16 @@
}
&__label {
display: flex;
vertical-align: middle;
gap: $pad-small; // For supporting icons
font-size: $x-small;
font-weight: $regular;
text-align: left;
vertical-align: text-bottom;
margin-left: $pad-small;
}
&__help-text {
@include help-text;
}
}
+17
View File
@@ -25,6 +25,7 @@ type InitialStateType = {
lastEditedQueryBody: string;
lastEditedQueryObserverCanRun: boolean;
lastEditedQueryFrequency: number;
lastEditedQueryAutomationsEnabled: boolean;
lastEditedQueryPlatforms: SelectedPlatformString;
lastEditedQueryMinOsqueryVersion: string;
lastEditedQueryLoggingType: QueryLoggingOption;
@@ -38,6 +39,7 @@ type InitialStateType = {
setLastEditedQueryBody: (value: string) => void;
setLastEditedQueryObserverCanRun: (value: boolean) => void;
setLastEditedQueryFrequency: (value: number) => void;
setLastEditedQueryAutomationsEnabled: (value: boolean) => void;
setLastEditedQueryPlatforms: (value: SelectedPlatformString) => void;
setLastEditedQueryMinOsqueryVersion: (value: string) => void;
setLastEditedQueryLoggingType: (value: string) => void;
@@ -59,6 +61,7 @@ const initialState = {
lastEditedQueryBody: DEFAULT_QUERY.query,
lastEditedQueryObserverCanRun: DEFAULT_QUERY.observer_can_run,
lastEditedQueryFrequency: DEFAULT_QUERY.interval,
lastEditedQueryAutomationsEnabled: DEFAULT_QUERY.automations_enabled,
lastEditedQueryPlatforms: DEFAULT_QUERY.platform,
lastEditedQueryMinOsqueryVersion: DEFAULT_QUERY.min_osquery_version,
lastEditedQueryLoggingType: DEFAULT_QUERY.logging,
@@ -72,6 +75,7 @@ const initialState = {
setLastEditedQueryBody: () => null,
setLastEditedQueryObserverCanRun: () => null,
setLastEditedQueryFrequency: () => null,
setLastEditedQueryAutomationsEnabled: () => null,
setLastEditedQueryPlatforms: () => null,
setLastEditedQueryMinOsqueryVersion: () => null,
setLastEditedQueryLoggingType: () => null,
@@ -125,6 +129,10 @@ const reducer = (state: InitialStateType, action: any) => {
typeof action.lastEditedQueryFrequency === "undefined"
? state.lastEditedQueryFrequency
: action.lastEditedQueryFrequency,
lastEditedQueryAutomationsEnabled:
typeof action.lastEditedQueryAutomationsEnabled === "undefined"
? state.lastEditedQueryAutomationsEnabled
: action.lastEditedQueryAutomationsEnabled,
lastEditedQueryPlatforms:
typeof action.lastEditedQueryPlatforms === "undefined"
? state.lastEditedQueryPlatforms
@@ -180,6 +188,7 @@ const QueryProvider = ({ children }: Props) => {
lastEditedQueryBody: state.lastEditedQueryBody,
lastEditedQueryObserverCanRun: state.lastEditedQueryObserverCanRun,
lastEditedQueryFrequency: state.lastEditedQueryFrequency,
lastEditedQueryAutomationsEnabled: state.lastEditedQueryAutomationsEnabled,
lastEditedQueryPlatforms: state.lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion: state.lastEditedQueryMinOsqueryVersion,
lastEditedQueryLoggingType: state.lastEditedQueryLoggingType,
@@ -225,6 +234,14 @@ const QueryProvider = ({ children }: Props) => {
lastEditedQueryFrequency,
});
},
setLastEditedQueryAutomationsEnabled: (
lastEditedQueryAutomationsEnabled: boolean
) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryAutomationsEnabled,
});
},
setLastEditedQueryPlatforms: (lastEditedQueryPlatforms: string) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
+2
View File
@@ -100,6 +100,7 @@ export interface IModifyQueryRequestBody
frequency?: number;
platform?: SelectedPlatformString;
min_osquery_version?: string;
automations_enabled?: boolean;
}
// response is ISchedulableQuery // better way to indicate this?
@@ -131,6 +132,7 @@ export interface IEditQueryFormFields {
observer_can_run: IFormField<boolean>;
discard_data: IFormField<boolean>;
frequency: IFormField<number>;
automations_enabled: IFormField<boolean>;
platforms: IFormField<SelectedPlatformString>;
min_osquery_version: IFormField<string>;
logging: IFormField<QueryLoggingOption>;
@@ -1,4 +1,4 @@
import React, { useContext, useState, useEffect } from "react";
import React, { useContext, useState } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router";
@@ -91,6 +91,7 @@ const EditQueryPage = ({
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryAutomationsEnabled,
lastEditedQueryPlatforms,
lastEditedQueryLoggingType,
lastEditedQueryMinOsqueryVersion,
@@ -101,6 +102,7 @@ const EditQueryPage = ({
setLastEditedQueryBody,
setLastEditedQueryObserverCanRun,
setLastEditedQueryFrequency,
setLastEditedQueryAutomationsEnabled,
setLastEditedQueryLoggingType,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryPlatforms,
@@ -150,6 +152,7 @@ const EditQueryPage = ({
setLastEditedQueryBody(returnedQuery.query);
setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run);
setLastEditedQueryFrequency(returnedQuery.interval);
setLastEditedQueryAutomationsEnabled(returnedQuery.automations_enabled);
setLastEditedQueryPlatforms(returnedQuery.platform);
setLastEditedQueryLoggingType(returnedQuery.logging);
setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version);
@@ -228,6 +231,7 @@ const EditQueryPage = ({
// Persist lastEditedQueryBody through live query flow instead of resetting to DEFAULT_QUERY.query
setLastEditedQueryObserverCanRun(DEFAULT_QUERY.observer_can_run);
setLastEditedQueryFrequency(DEFAULT_QUERY.interval);
setLastEditedQueryAutomationsEnabled(DEFAULT_QUERY.automations_enabled);
setLastEditedQueryLoggingType(DEFAULT_QUERY.logging);
setLastEditedQueryMinOsqueryVersion(DEFAULT_QUERY.min_osquery_version);
setLastEditedQueryPlatforms(DEFAULT_QUERY.platform);
@@ -298,6 +302,7 @@ const EditQueryPage = ({
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryAutomationsEnabled,
lastEditedQueryPlatforms,
lastEditedQueryLoggingType,
lastEditedQueryMinOsqueryVersion,
@@ -17,7 +17,11 @@ describe("DiscardDataOption component", () => {
);
expect(screen.getByText(/Discard data/)).toBeInTheDocument();
expect(screen.getByText(/Data will still be sent/)).toBeInTheDocument();
expect(
screen.getByText(
/The most recent results for each host will not be available in Fleet./
)
).toBeInTheDocument();
});
it('Renders the "disabled" help text with tooltip when the global option is disabled', async () => {
@@ -48,7 +52,11 @@ describe("DiscardDataOption component", () => {
await fireEvent.click(screen.getByText(/Edit anyway/));
// normal text
expect(screen.getByText(/Data will still be sent/)).toBeInTheDocument();
expect(
screen.getByText(
/The most recent results for each host will not be available in Fleet./
)
).toBeInTheDocument();
});
it('Renders the info banner when "Differential" logging option is selected', () => {
render(
@@ -13,7 +13,6 @@ interface IDiscardDataOptionProps {
selectedLoggingType: QueryLoggingOption;
discardData: boolean;
setDiscardData: (value: boolean) => void;
breakHelpText?: boolean;
}
const DiscardDataOption = ({
@@ -21,7 +20,6 @@ const DiscardDataOption = ({
selectedLoggingType,
discardData,
setDiscardData,
breakHelpText = false,
}: IDiscardDataOptionProps) => {
const [forceEditDiscardData, setForceEditDiscardData] = useState(false);
const disable = queryReportsDisabled && !forceEditDiscardData;
@@ -60,14 +58,7 @@ const DiscardDataOption = ({
</Link>
</>
) : (
<>
The most recent results for each host will not be available in Fleet.
{breakHelpText ? <br /> : " "}
Data will still be sent to your log destination if <b>
automations
</b>{" "}
are <b>on</b>.
</>
"The most recent results for each host will not be available in Fleet."
)}
</div>
);
@@ -1,5 +1,5 @@
import React from "react";
import { screen } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import createMockQuery from "__mocks__/queryMock";
@@ -32,6 +32,7 @@ describe("EditQueryForm - component", () => {
lastEditedQueryBody: mockQuery.query,
lastEditedQueryObserverCanRun: mockQuery.observer_can_run,
lastEditedQueryFrequency: mockQuery.interval,
lastEditedQueryAutomationsEnabled: mockQuery.automations_enabled,
lastEditedQueryPlatforms: mockQuery.platform,
lastEditedQueryMinOsqueryVersion: mockQuery.min_osquery_version,
lastEditedQueryLoggingType: mockQuery.logging,
@@ -40,6 +41,7 @@ describe("EditQueryForm - component", () => {
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryObserverCanRun: jest.fn(),
setLastEditedQueryFrequency: jest.fn(),
setLastEditedQueryAutomationsEnabled: jest.fn(),
setLastEditedQueryPlatforms: jest.fn(),
setLastEditedQueryMinOsqueryVersion: jest.fn(),
setLastEditedQueryLoggingType: jest.fn(),
@@ -92,6 +94,7 @@ describe("EditQueryForm - component", () => {
lastEditedQueryBody: mockQuery.query,
lastEditedQueryObserverCanRun: mockQuery.observer_can_run,
lastEditedQueryFrequency: mockQuery.interval,
lastEditedQueryAutomationsEnabled: mockQuery.automations_enabled,
lastEditedQueryPlatforms: mockQuery.platform,
lastEditedQueryMinOsqueryVersion: mockQuery.min_osquery_version,
lastEditedQueryLoggingType: mockQuery.logging,
@@ -100,6 +103,7 @@ describe("EditQueryForm - component", () => {
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryObserverCanRun: jest.fn(),
setLastEditedQueryFrequency: jest.fn(),
setLastEditedQueryAutomationsEnabled: jest.fn(),
setLastEditedQueryPlatforms: jest.fn(),
setLastEditedQueryMinOsqueryVersion: jest.fn(),
setLastEditedQueryLoggingType: jest.fn(),
@@ -152,6 +156,91 @@ describe("EditQueryForm - component", () => {
/live queries are disabled/i
);
});
it("shows automations warning icon when query frequency is set to 0", async () => {
const render = createCustomRenderer({
context: {
query: {
lastEditedQueryId: mockQuery.id,
lastEditedQueryName: "Test Query",
lastEditedQueryDescription: mockQuery.description,
lastEditedQueryBody: mockQuery.query,
lastEditedQueryObserverCanRun: mockQuery.observer_can_run,
lastEditedQueryFrequency: 0, // Set frequency to 0
lastEditedQueryAutomationsEnabled: true, // Enable automations
lastEditedQueryPlatforms: mockQuery.platform,
lastEditedQueryMinOsqueryVersion: mockQuery.min_osquery_version,
lastEditedQueryLoggingType: mockQuery.logging,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryObserverCanRun: jest.fn(),
setLastEditedQueryAutomationsEnabled: jest.fn(),
setLastEditedQueryFrequency: jest.fn(),
setLastEditedQueryPlatforms: jest.fn(),
setLastEditedQueryMinOsqueryVersion: jest.fn(),
setLastEditedQueryLoggingType: jest.fn(),
},
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig(),
},
},
});
const { user } = render(
<EditQueryForm
router={mockRouter}
queryIdForEdit={1}
apiTeamIdForQuery={1}
teamNameForQuery="Apples"
showOpenSchemaActionText
storedQuery={createMockQuery({ interval: 0 })}
isStoredQueryLoading={false}
isQuerySaving={false}
isQueryUpdating={false}
onSubmitNewQuery={jest.fn()}
onOsqueryTableSelect={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
showConfirmSaveChangesModal={false}
setShowConfirmSaveChangesModal={jest.fn()}
/>
);
// Find the frequency dropdown
const frequencyDropdown = screen
.getByText("Frequency")
.closest(".form-field--dropdown") as HTMLElement;
expect(frequencyDropdown).toBeInTheDocument();
// Check if the frequency is set to "Never"
const selectedFrequency = within(frequencyDropdown).getByText("Never");
expect(selectedFrequency).toBeInTheDocument();
// Find the automations slider
const automationsSlider = screen
.getByText("Automations on")
.closest(".fleet-slider__wrapper") as HTMLElement;
expect(automationsSlider).toBeInTheDocument();
// Check if the automations are enabled
const automationsButton = within(automationsSlider).getByRole("button");
expect(automationsButton).toHaveClass("fleet-slider--active");
// Check if the warning icon is present
const warningIcon = within(automationsSlider).getByTestId("warning-icon");
expect(warningIcon).toBeInTheDocument();
});
// TODO: Consider testing save button is disabled for a sql error
// Trickiness is in modifying react-ace using react-testing library
});
@@ -52,6 +52,8 @@ import RevealButton from "components/buttons/RevealButton";
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Slider from "components/forms/fields/Slider";
import TooltipWrapper from "components/TooltipWrapper";
import Spinner from "components/Spinner";
import Icon from "components/Icon/Icon";
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
@@ -127,6 +129,7 @@ const EditQueryForm = ({
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryAutomationsEnabled,
lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion,
lastEditedQueryLoggingType,
@@ -136,6 +139,7 @@ const EditQueryForm = ({
setLastEditedQueryBody,
setLastEditedQueryObserverCanRun,
setLastEditedQueryFrequency,
setLastEditedQueryAutomationsEnabled,
setLastEditedQueryPlatforms,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryLoggingType,
@@ -176,6 +180,8 @@ const EditQueryForm = ({
const platformCompatibility = usePlatformCompatibility();
const { setCompatiblePlatforms } = platformCompatibility;
const logDestination = config?.logging.result.plugin || "";
const debounceSQL = useDebouncedCallback((sql: string) => {
const { errors: newErrors } = validateQuerySQL(sql);
@@ -316,6 +322,7 @@ const EditQueryForm = ({
team_id: apiTeamIdForQuery,
observer_can_run: lastEditedQueryObserverCanRun,
interval: lastEditedQueryFrequency,
automations_enabled: lastEditedQueryAutomationsEnabled,
platform: lastEditedQueryPlatforms,
min_osquery_version: lastEditedQueryMinOsqueryVersion,
logging: lastEditedQueryLoggingType,
@@ -341,6 +348,7 @@ const EditQueryForm = ({
team_id: apiTeamIdForQuery,
observer_can_run: lastEditedQueryObserverCanRun,
interval: lastEditedQueryFrequency,
automations_enabled: lastEditedQueryAutomationsEnabled,
platform: lastEditedQueryPlatforms,
min_osquery_version: lastEditedQueryMinOsqueryVersion,
logging: lastEditedQueryLoggingType,
@@ -412,6 +420,7 @@ const EditQueryForm = ({
query: lastEditedQueryBody,
observer_can_run: lastEditedQueryObserverCanRun,
interval: lastEditedQueryFrequency,
automations_enabled: lastEditedQueryAutomationsEnabled,
platform: lastEditedQueryPlatforms,
min_osquery_version: lastEditedQueryMinOsqueryVersion,
logging: lastEditedQueryLoggingType,
@@ -724,6 +733,43 @@ const EditQueryForm = ({
wrapperClassName={`${baseClass}__form-field form-field--frequency`}
helpText="This is how often your query collects data."
/>
<Slider
onChange={() =>
setLastEditedQueryAutomationsEnabled(
!lastEditedQueryAutomationsEnabled
)
}
value={lastEditedQueryAutomationsEnabled}
activeText={
<>
Automations on
{lastEditedQueryFrequency === 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
{!lastEditedQueryAutomationsEnabled ? " not " : " "}be sent
to your log destination: <b>{logDestination}</b>.
</>
}
/>
<Checkbox
value={lastEditedQueryObserverCanRun}
onChange={(value: boolean) =>
@@ -0,0 +1,88 @@
import React from "react";
import { screen, within } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import createMockQuery from "__mocks__/queryMock";
import createMockUser from "__mocks__/userMock";
import createMockConfig from "__mocks__/configMock";
import SaveQueryModal from "./SaveQueryModal";
const mockQuery = createMockQuery();
describe("SaveQueryModal", () => {
const defaultProps = {
queryValue: "SELECT * FROM users",
apiTeamIdForQuery: 1,
isLoading: false,
saveQuery: jest.fn(),
toggleSaveQueryModal: jest.fn(),
backendValidators: {},
existingQuery: mockQuery,
queryReportsDisabled: false,
};
it("renders the modal with initial values and allows editing", async () => {
const render = createCustomRenderer({
context: {
app: {
currentUser: createMockUser(),
config: createMockConfig(),
},
},
});
const { user } = render(<SaveQueryModal {...defaultProps} />);
expect(screen.getByLabelText("Name")).toBeInTheDocument();
expect(screen.getByLabelText("Description")).toBeInTheDocument();
expect(screen.getByText("Frequency")).toBeInTheDocument();
expect(screen.getByText("Observers can run")).toBeInTheDocument();
expect(screen.getByText("Automations off")).toBeInTheDocument();
expect(screen.getByText("Show advanced options")).toBeInTheDocument();
const nameInput = screen.getByLabelText("Name");
await user.type(nameInput, "Test Query");
expect(nameInput).toHaveValue("Test Query");
});
it("toggles advanced options", async () => {
const render = createCustomRenderer({
context: {
app: {
currentUser: createMockUser(),
config: createMockConfig(),
},
},
});
const { user } = render(<SaveQueryModal {...defaultProps} />);
const advancedOptionsButton = screen.getByText("Show advanced options");
await user.click(advancedOptionsButton);
expect(screen.getByText("Platforms")).toBeInTheDocument();
expect(screen.getByText("Minimum osquery version")).toBeInTheDocument();
expect(screen.getByText("Logging")).toBeInTheDocument();
await user.click(advancedOptionsButton);
expect(screen.queryByText("Platforms")).not.toBeInTheDocument();
});
it("displays error when query name is empty", async () => {
const render = createCustomRenderer({
context: {
app: {
currentUser: createMockUser(),
config: createMockConfig(),
},
},
});
const { user } = render(<SaveQueryModal {...defaultProps} />);
await user.click(screen.getByText("Save"));
expect(screen.getByText("Query name must be present")).toBeInTheDocument();
});
});
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useContext } from "react";
import { pull, size } from "lodash";
import { AppContext } from "context/app";
import useDeepEffect from "hooks/useDeepEffect";
import Checkbox from "components/forms/fields/Checkbox";
@@ -8,6 +10,9 @@ import Checkbox from "components/forms/fields/Checkbox";
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 {
@@ -58,6 +63,8 @@ const SaveQueryModal = ({
existingQuery,
queryReportsDisabled,
}: ISaveQueryModalProps): JSX.Element => {
const { config } = useContext(AppContext);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedFrequency, setSelectedFrequency] = useState(
@@ -76,12 +83,15 @@ const SaveQueryModal = ({
setSelectedLoggingType,
] = useState<QueryLoggingOption>(existingQuery?.logging ?? "snapshot");
const [observerCanRun, setObserverCanRun] = useState(false);
const [automationsEnabled, setAutomationsEnabled] = useState(false);
const [discardData, setDiscardData] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const logDestination = config?.logging.result.plugin || "";
const toggleAdvancedOptions = () => {
setShowAdvancedOptions(!showAdvancedOptions);
};
@@ -115,6 +125,7 @@ const SaveQueryModal = ({
description,
interval: selectedFrequency,
observer_can_run: observerCanRun,
automations_enabled: automationsEnabled,
discard_data: discardData,
platform: selectedPlatformOptions,
min_osquery_version: selectedMinOsqueryVersionOptions,
@@ -197,6 +208,38 @@ const SaveQueryModal = ({
>
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>{logDestination}</b>.
</>
}
/>
<RevealButton
isShowing={showAdvancedOptions}
className="advanced-options-toggle"